The in-tree ML-KEM-768 was round-3 CRYSTALS-Kyber mislabelled "FIPS 203" and was NOT interoperable with a compliant ML-KEM. Discovered and fixed by validating against OpenSSL 3.5's FIPS 203 ML-KEM-768 as an oracle. Three deviations, all fixed in src/zupt_mlkem.c: 1. Matrix  transpose convention. FIPS 203 K-PKE.KeyGen samples Â[i][j] = SampleNTT(XOF(ρ, j, i)) and K-PKE.Encrypt uses (ρ, i, j); the code had both index orders swapped. It was self-consistent (encaps/decaps round-tripped) but transposed vs the standard — which is exactly why a self-consistency-only round-trip test never caught it. With the same seed, keygen now produces a byte-identical ek to OpenSSL. 2. Shared-secret KDF. FIPS 203 returns K = G(m‖H(ek))[0:32] directly; removed the round-3 final K = KDF(K̄‖H(c)) step (encaps + decaps success key). 3. Implicit rejection. Now K̄ = J(z‖c) = SHAKE256(z ‖ full-ciphertext) instead of the round-3 KDF(z‖H(c)). Validation (tests/test_mlkem_fips203.sh + mlkem_fips203_harness.c, wired into make check): against OpenSSL 3.5 ML-KEM-768 — - deterministic keygen (same d‖z seed) -> byte-identical ek - our encaps -> OpenSSL decap: shared secret matches - OpenSSL encap -> our decaps: shared secret matches The harness feeds a fixed random stream (MLKEM_RAND) so the FIPS 203 seed is reproducible; the test skips gracefully without an ML-KEM-capable openssl. BREAKING: --pq / --pq-only keys and archives from <= 4.2.1 no longer decrypt (the KEM math changed). Regenerate keys and re-encrypt. Password mode and plain compression are unaffected; wire format stays v1.6. make check 16/16.
208 KiB
VaptVupt Changelog
[5.0.0] — unreleased — genuine FIPS 203 ML-KEM-768 (breaking PQ change)
Security / correctness — ML-KEM-768 is now FIPS 203-conformant
- The in-tree ML-KEM-768 was round-3 CRYSTALS-Kyber, not final FIPS 203, and
therefore not interoperable with a compliant ML-KEM despite the "FIPS 203"
label. Three deviations were found and fixed:
- Matrix  transpose convention — K-PKE.KeyGen must use
SampleNTT(ρ‖j‖i)and K-PKE.EncryptSampleNTT(ρ‖i‖j); the implementation had both swapped. Self-consistent (round-trips passed) but non-standard, which is exactly why a self-consistency-only test never caught it. - Encaps/decaps KDF — FIPS 203 outputs
KfromG(m‖H(ek))directly; the round-3 finalK = KDF(K̄‖H(c))step was removed. - Implicit rejection — now
K̄ = J(z‖c)(SHAKE256 over the full ciphertext) instead ofKDF(z‖H(c)).
- Matrix  transpose convention — K-PKE.KeyGen must use
- Validated for genuine conformance against OpenSSL 3.5's FIPS 203 ML-KEM-768
(
tests/test_mlkem_fips203.sh, wired intomake check): deterministic keygen produces byte-identicalek, and the shared secret matches in both cross-decapsulation directions (our encaps ↔ OpenSSL decaps, and vice-versa). This replaces the previous self-consistency-only round-trip test.
BREAKING
--pqand--pq-onlykeys and archives created by ≤ 4.2.1 are not readable by this release (the KEM math changed). Regenerate keys (keygen/keygen --pq-only) and re-encrypt affected archives. Password mode (-p) and plain compression are unaffected. Wire format stays v1.6.
[4.2.1] — 2026-07-10 — info correctly reports the post-quantum mode
Fixed
vaptvupt infomislabelled--pq-onlyarchives as hybrid. Full post-quantum archives set the genericZUPT_FLAG_PQ_HYBRIDheader flag (theenc_typebyte is what distinguishes hybrid0x02from pure0x06), butinfoonly checked the flag and always printed "PQ Hybrid: YES (ML-KEM-768 + X25519)". It now reads the realenc_typefrom the encryption-header block and reports the actual mode: "ML-KEM-768 only, no classical layer" for--pq-only, and hybrid / SDK-v2 / sealed-box for the others. Reader-side only — no wire-format change; existing 4.2.0 archives are relabelled correctly with no re-encryption. The crypto was always correct; only theinfolabel was wrong.
[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-onlyuses ML-KEM-768 (FIPS 203) as the sole key-establishment mechanism, with no classical X25519 component. It complements the existing hybrid--pqfor 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 isSHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1"). Keypairs use theZPQKmagic (1200-byte public, 3600-byte private) and are not interchangeable with hybrid--pqkeys. - Keygen:
vaptvupt keygen --pq-onlyandkeygen --pub --pq-only. Encrypt withcompress --pq-only pub.key, restore withextract --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-onlyhas 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/--boxon a source-only build now fails with a clear message pointing to the native--pq/--pq-onlykeygen (or aWITH_SDK=1build) instead of an opaque error.
Security
- Critical — AES-256-CTR keystream reuse under deduplication. In
--dedupmode 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 asbase_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_seqis still bound as MAC AAD. Nonces are now distinct across all blocks in every mode (regression testtests/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)
- The prebuilt vendored libraries
libzuptsdk.soandlibpqvaptvupt.soare removed from the repository. The default build (make) now compiles entirely from the in-tree C sources with no external library dependency and installs no shared object. - The libzuptsdk-backed modes — the Argon2id password KDF,
--pq-sdk, and--pq-box— are gated behind an opt-inmake WITH_SDK=1build against the separately distributed libraries. In the default build they report as unsupported. The default password KDF is PBKDF2-SHA256 (600k iterations) and--pq(native ML-KEM-768 + X25519) is the built-in PQ mode. - openSUSE packaging builds source-only:
%build/%installpassWITH_SDK=0and%filesno longer ships the.so.
Fixed
- Multithreaded encrypted archives were unextractable on the native AEAD
path. The parallel compress/decompress workers used the non-preface
encrypt/decrypt calls, but the archive's
ZUPT_FLAG_AAD_PREFACE(F-09) and the serial path bind the 29-byte frame preface into every block MAC — so each multithreaded block failed authentication. The workers now bind the preface via the sharedzupt_serialize_preface_aad_scalars. Output is byte-identical across thread counts, and archives interoperate between single- and multithreaded compress/extract. Also fixes--kdf pbkdf2 -t N.
Security
- LZH decoder: bound the raw code-length header against the destination stack
buffers and reject code lengths > 15 (stack overflow +
huff_lutOOB write on crafted archives). - Format parser: overflow-safe bounds in
parse_indexand solid-mode extract (heap OOB reads via wrapped 64-bit length/offset fields). - ANS SEQ decoder: reserve a full worst-case sequence (litlen + matchlen) in the fast path (heap OOB write past the output buffer).
- Require the per-block ENCRYPTED flag on every block of an encrypted archive (plaintext-injection / authentication bypass).
- Cap the archive-supplied PBKDF2 iteration count (KDF-amplification DoS).
- Non-elidable secret wipe on the SDK crypto path; restored disk images are created with mode 0600.
Wire format v1.6 unchanged; pre-4.1 archives remain readable.
[4.0.0] — 2026-06-10 — Codec 2.60.4 (security), pq-box mode, F-16 disclosure
Major release: the vendored codec moves to the canonical VaptVupt
2.60.4 security release, a third post-quantum recipient mode
(--pq-box, vendored libpqvaptvupt 0.6.0) lands, and a pre-existing
data-loss defect (F-16) in the old in-tree BCJ encoder is disclosed
and fixed. Wire format stays v1.6; every readable pre-4.0 archive
remains readable (proof matrix below).
Codec: 2.53.3-era → 2.60.4 (security release)
- Fixes a high-severity OOB heap write in the AVX2 decode fast path,
reachable on a valid stream when the output buffer is sized to
exactly
content_size(both tail variants,n ≤ 32andn > 32). The tool itself was shielded by its F-14 decode slack; the vendored codec is now correct on its own. New regression testtests/test_codec_exact_size.{c,sh}: 80 exact-size decode cases (tail coverage, BCJ-triggering ELF-like payloads, stored path) under AddressSanitizer, plus tool-level BCJ roundtrips at L5/L9. - Ratio gate verified on identical inputs: archives produced by the shipped 3.8.0 binary and by 4.0.0 are byte-identical in size for text/source/redundant (Δ 0.00 %); see BENCHMARKS.md §1.
- Brings the canonical, CBMC-formally-verified BCJ filters (upstream v2.56.0) with automatic ELF/PE/Mach-O detection (v2.55.0), enabled for levels ≥ 3.
- Codec release string is now single-sourced (
ZUPT_CODEC_RELEASE).
F-16 — data-loss defect in ≤ 3.8.0 BCJ encoding (pre-existing, fixed)
The ≤ 3.8.0 tree carried a divergent pre-release BCJ (upstream
2.53.3 contains no BCJ at all; it landed upstream in 2.53.4). On
BCJ-detected binary content at levels 8–9, that encoder wrote archives
that no version can decode — including 3.8.0 itself (verified:
old binary fails on its own archive; the defect is at write time).
Non-BCJ content and levels ≤ 7 are unaffected; the 8-mode back-compat
matrix (plain L1/L5/L9, store, Argon2id, PBKDF2, legacy --pq,
--pq-sdk) decodes byte-exact under 4.0.0.
Action required for affected users: archives created by ≤ 3.8.0 at
-l 8/-l 9 whose inputs included x86/ELF/PE executables should be
re-created with 4.0.0 (verify with vaptvupt x before deleting any
source data). 4.0.0's BCJ streams are canonical; note that tools
≤ 3.8.0 cannot read new archives where the auto-filter fired
(L3+ on executable content) — upgrade readers first in mixed fleets.
New: --pq-box recipient encryption (ZUPT_ENC_PQ_BOX_V1, 0x05)
Third PQ mode, backed by vendored libpqvaptvupt 0.6.0 (AGPL-3.0-or- later + commercial; its own suite: 66/66):
- ML-KEM-768 + X25519 shared secrets combined through HKDF-SHA256
Extract/Expand with a domain-separating info (
"pqvv-seal-v1") — the combiner this project's crypto standing orders prescribe (the legacy--pqXOR+SHA3 combiner and--pq-sdkremain for back-compat). - AES-256-CTR + HMAC-SHA256 Encrypt-then-MAC inside the box; SHA-NI
runtime dispatch; CSPRNG hardened against blocked/ENOSYS
getrandom(2). keygen --boxwrites magic-tagged keypair files (PQVVBOX1+ role byte) — public/secret/legacy key files are mutually rejected, eliminating key-type confusion.- Envelope:
[0x05][4B LE len][pqvv_seal(session_key)]; the 32-byte session key is split into enc/mac keys with domain-separated SHA3, mirroring the proven SDK path so all per-block machinery is shared. - One-time cost ≈ 3 ms seal / 3 ms open (measured).
- New suite
tests/test_pqbox.sh(13 checks): L1/L9/BCJ roundtrips byte-exact; wrong-key, public-as-secret, secret-as-public, legacy-key, envelope-tamper, data-tamper, password-on-box all rejected. ASan+UBSan clean on seal, open, and the wrong-key cleanup path.
Measured performance (this release's box: Xeon 2.10 GHz, SHA-NI)
- SHA-NI finally measured: SHA-256 scalar 204 MB/s → SHA-NI
1184 MB/s, 5.8× (256 MiB, same box). The v3.2.0
[ESTIMATED 3-8×]label is retired. - Encrypted per-block throughput 293 MB/s (Argon2id mode) — ~2× the 3.8.0-era figure on a slower clock, the EtM second pass now on SHA-NI.
- Full tables in BENCHMARKS.md (fixtures regenerated on this box; the v3.8.0 edition's absolute numbers are superseded, codec stability is proven by the same-input gate, not cross-edition comparison).
Toolchain and build fixes
- Jasmin
.sfiles are assembled withas(1)directly — clang 18's integrated assembler rejects GNU-as macro/comment style (clang strict build restored: 0 warnings on gcc and clang). - Vendored codec objects build under an explicit upstream warning policy (two benign clang-only categories) instead of patching pristine upstream files.
VV_SOURCESgainedvv_bcj.c(thetest-asantarget could not link since BCJ arrived).- Corrected a Makefile comment that misstated the codec license as "Apache-2.0 / MIT" — the codec is GPL-3.0-or-later; the tool is AGPL-3.0-or-later (never MIT).
Compatibility summary
| Direction | Result |
|---|---|
| 4.0.0 reads ≤ 3.8.0 archives | ✓ byte-exact, all 8 modes/levels tested (except F-16-corrupted L8/L9 BCJ archives, which were never readable by anything) |
| ≤ 3.8.0 reads 4.0.0 archives | ✓ for non-filtered content; ✗ where BCJ auto-filter fired (L3+ on executables) — upgrade readers first |
--pq-box archives |
require ≥ 4.0.0 |
| Wire format | v1.6, unchanged |
Files touched
src/v* include/v* (codec → upstream 2.60.4, byte-exact; shim retained)
src/zupt_crypto_pqbox.c (NEW — pq-box mode)
src/zupt_format.c (0x05 dispatch, both directions)
src/zupt_main.c (CLI: --pq-box, keygen --box, help)
include/zupt.h (4.0.0; ZUPT_ENC_PQ_BOX_V1; ZUPT_CODEC_RELEASE; prototypes; box_mode)
vendor/pqvaptvupt/ (NEW — libpqvaptvupt 0.6.0 + header + LICENSE)
tests/test_codec_exact_size.{c,sh}, tests/test_pqbox.sh (NEW)
Makefile (pqvv include/link; as(1) for .s; VV warning policy; VV_SOURCES+bcj; license comment)
doc/vaptvupt.1, BENCHMARKS.md, README.md, ROADMAP.md, AUDIT.md, packaging/*
[3.8.0] — 2026-06-01 — Consolidated measured benchmarks + constant-time test robustness
Two changes, neither touching the shipped crypto or the wire format (v1.6, binary behaviour identical to 3.7.0): a consolidated measured benchmark document, and a robustness fix to the constant-time timing test so it never reports a noise-driven false failure.
Constant-time test: no more false failures under vCPU contention
The dudect-style timing test (tests/test_ct_timing.c) compares
zupt_ct_memeq's data-dependent timing against a leaky-memcmp control
via their ratio. On a quiet host the control leaks strongly
(|t| ≈ 600–1500) and zupt_ct_memeq is flat (|t| ≈ 5–70, ratio
≈ 0.01–0.05). But under heavy shared-vCPU contention both collapse
into a common noise band (control ≈ 210, ct ≈ 190), making the ratio
(≈ 0.9) a noise artifact rather than a real leak — which produced
intermittent false failures.
Fix: the test now renders a pass/fail verdict only when the control leaks strongly (|t| ≥ 400, comfortably above the observed ~210 contention band and below the ~600+ quiet floor). Below that it reports INCONCLUSIVE (exit 0) instead of failing. A genuine early-return regression still fails on a quiet host (the leaky function tracks the control, ratio → ~1.0, with the control well above 400). The source-routing guard (decaps + MAC compare must use the audited primitive) runs unconditionally. This makes the security regression test trustworthy: it never cries wolf from measurement noise, and still catches a real leak when the host can measure one.
New BENCHMARKS.md
A single reproducible benchmark document, with the test machine, build, and method stated for every table:
- Compression ratio + encode/decode throughput at level 9 across the 5-fixture suite (text, binary, source, redundant, random).
- Encode speed vs level (1/3/5/7/9) showing the ratio↔speed trade-off (level 1 ≈ 88 MB/s at 2.55×, level 9 ≈ 1 MB/s at 3.90× on text).
- Encryption overhead via store-mode measurement that separates the one-time KDF (Argon2id ≈ 741 ms, PBKDF2 ≈ 1562 ms on the test box) from per-block crypto (≈ 147 MB/s), and plain throughput (≈ 944 MB/s single-threaded).
- Head-to-head ratio vs zstd-3 / zstd-19 — shown plainly, including where VaptVupt loses (zstd-19 wins ratio on every fixture; zstd-3 edges out VaptVupt-L9 on text/binary).
- A clear statement that the codec is not the reason to use VaptVupt — the value is the combination of PQ-hybrid encryption, Argon2id, per-block authenticated encryption, and formally-verified constant-time crypto.
Honesty notes baked into the document
- Every number is labeled measured; the SHA-NI speedup is explicitly marked [ESTIMATED] because the test box has no SHA-NI.
- The KDF cost is presented as intentional (memory-hardness), not as a deficiency to optimize away.
- Reproduction commands are included; the document states that absolute numbers vary by machine while the shape is stable.
Documentation alignment
README.mdbenchmark section re-dated v3.1.0 → v3.8.0 and now links toBENCHMARKS.md.CHANGELOG.md,ROADMAP.md,AUDIT.mdupdated for 3.8.0.
Test status
24/24 suites green (the constant-time suite reports a real verdict
on a quiet host and INCONCLUSIVE — never a false failure — under
contention). test_vectors 16/0, F-09 0/1827, F-06 0/2000.
Wire format v1.6.
Files touched
include/zupt.h (version 3.7.0 → 3.8.0)
doc/vaptvupt.1 (TH version 3.8.0)
BENCHMARKS.md (NEW — consolidated measured benchmarks)
tests/test_ct_timing.c (robust verdict: INCONCLUSIVE under contention, never false-fail)
README.md (benchmark section re-dated + links to BENCHMARKS.md)
ROADMAP.md, AUDIT.md (3.8.0 entries)
packaging/* (version 3.7.0 → 3.8.0; Debian + openSUSE changelog entries)
[3.7.0] — 2026-06-01 — ML-KEM decaps comparison routed through the audited CT primitive
Closes the last security-critical comparison still using a bespoke
inline loop: the ML-KEM-768 decapsulation implicit-rejection check now
uses the same measured-constant-time zupt_ct_memeq as the MAC tag
compare. No wire-format change; ML-KEM output identical.
The gap
Sprint 3.5.0 consolidated the MAC tag comparison into one audited,
timing-tested primitive (zupt_ct_memeq). But the ML-KEM-768 decaps
implicit-rejection comparison — ct vs the re-encrypted ct' over all
1088 ciphertext bytes — was still a separate inline byte-OR loop
marked CT-REQUIRED but never measured and not sharing the audited
primitive. A timing leak there is a KEM decapsulation oracle:
distinguishing valid from invalid ciphertexts breaks IND-CCA2 security.
This was the last such inline compare in the codebase.
What changed
zupt_mlkem768_decapsnow callszupt_ct_memeq(ct, ct_prime, 1088)instead of an inline loop. The primitive returns equality (1 if the ciphertext matches → success), and the implicit-rejection fail bit is derived asfail = 1 - equal. The Jasminzupt_ct_select_32key selection (and its Ccmovfallback) are unchanged.- ML-KEM output is byte-identical. A matching ciphertext yields the success shared secret; a mismatched one yields the pseudorandom rejection key — exactly as before. Verified by the FIPS 203 roundtrip (5 trials), the implicit-rejection vector, a full PQ-hybrid roundtrip, and wrong-key rejection.
Verification — and an honest scoping decision
tests/test_ct_timing is extended to the 1088-byte length and gains a
source-routing guard that fails if the decaps compare stops using
zupt_ct_memeq or a raw 1088-byte inline loop reappears.
The 1088-byte dudect timing numbers are reported as informational, not
pass/fail, and the test documents why: at that buffer size on a shared
vCPU the measurement is dominated by memory/cache effects rather than the
compare's control flow, and plain memcmp over 1088 bytes is no longer a
cleanly-leaking control (its own timing is data-dependent for reasons
unrelated to early-exit). The environment-relative ratio that is
meaningful at 32 bytes does not transfer to 1088 bytes, and tuning a
threshold to make it "pass" would be dishonest. Instead, the
constant-timeness of the 1088-byte decaps compare follows rigorously
from three facts that are established here:
- the 32-byte pass/fail dudect check proves
zupt_ct_memeqis constant-time (data-dependent signal ~1–5% of a leaky-memcmpcontrol, median of 5 runs); zupt_ct_memeqis length-independent by construction — OR-accumulate, no early exit, no data-dependent branch, the same code path for every byte and every length; and- the source-routing guard confirms decaps uses exactly this primitive.
This is a stronger argument than a flaky large-buffer timing run, and it is honest about what the measurement can and cannot show on this host.
Security
- The last inline CT comparison is gone; every security-critical comparison (MAC tag, archive-integrity trailer, ML-KEM decaps) now routes through one audited, timing-tested, length-independent primitive.
- KEM decapsulation-oracle resistance is now backed by the primitive's measured constant-timeness plus a routing regression guard, not just a source comment.
- F-09 byte sweep 0/1827, F-06 0/2000,
test_vectors16/0 — all unchanged.
Performance
Neutral — same comparison work, now through a shared function (which the
compiler inlines at -O2).
Test status
24/24 suites green (the constant-time suite now covers the MAC tag
and the ML-KEM ciphertext compare, plus the source-routing guard).
Strict GCC -Werror clean. Wire format unchanged (v1.6).
Files touched
include/zupt.h (version 3.6.0 → 3.7.0)
doc/vaptvupt.1 (TH version 3.7.0)
src/zupt_mlkem.c (decaps: route the 1088-byte compare through zupt_ct_memeq)
tests/test_ct_timing.c (parameterise over length; add informational 1088B measurement)
tests/test_ct_timing.sh (add source-routing guard for the decaps compare)
README.md, ROADMAP.md, AUDIT.md (3.7.0 entries)
packaging/* (version 3.6.0 → 3.7.0; Debian + openSUSE changelog entries)
[3.6.0] — 2026-06-01 — NIST SP 800-38A AES-256-CTR vectors + ML-KEM self-test fixes
Closes a real test-coverage gap (the bulk cipher had no standards known-answer test) and fixes two latent bugs in the ML-KEM self-test reporting and logic. No source-crypto behaviour change, no wire-format change.
AES-256-CTR known-answer vectors (the gap)
test_vectors covered SHA-256, HMAC-SHA256, SHA3-256, SHAKE-128,
X25519, ML-KEM-768, and XXH64 — but had no AES known-answer test.
AES-256-CTR is the bulk cipher (every encrypted byte goes through it),
and it was only exercised indirectly via roundtrips, which prove
self-consistency but not conformance to the standard. The project's own
engineering requirements list SP 800-38A as a required vector, and
the README claimed "13 NIST/RFC test vectors" with AES absent from them.
Added the canonical NIST SP 800-38A AES-256-CTR vectors:
- F.5.5 CTR-AES256.Encrypt (4 plaintext blocks → 4 ciphertext blocks)
- F.5.6 CTR-AES256.Decrypt (symmetric verification)
These validate zupt_aes256_ctr against the standard on both code
paths: the Jasmin AES-NI assembly (zupt_aes256_ctr4 + zupt_aes256_blk)
on x86_64 with -DZUPT_USE_JASMIN, and the C T-table fallback elsewhere.
Both match exactly — which also confirms the Jasmin AES single-block
function is correct against the standard, retiring the stale concern
about a stack-offset issue in zupt_aes256_blk.
(Counter note: SP 800-38A increments the full 128-bit block while Zupt
increments the low 64 bits. The two coincide for the standard's 4-block
example because the IV's low byte is 0xff and the carries stay within
the low 8 bytes, so this is an exact KAT — documented in the test.)
ML-KEM-768 self-test: two fixes
-
Inverted result check (reporting bug).
zupt_mlkem768_selftest()returns 0 on success / -1 on failure, buttest_vectorscheckedif (ok)— printing "OK" precisely when the self-test failed and "FAIL" when it passed. The self-test line had been passing vacuously. Nowif (rc == 0). -
NTT roundtrip self-test logic (false-failure bug). The self-test asserted
ntt∘inv_ntt == identity, which is false for this pqcrystals/Kyber Montgomery convention: the forwardntt()applies a baremontgomery_reduceper butterfly (dividing byR = 2^16) without first mapping the input into the Montgomery domain, so the roundtrip recovers each coefficient scaled by a fixed constant (R⁻¹ mod q = 169). The real pipeline corrects for this viabasemul+tomont. The self-test now verifies the true invariant — that the roundtrip is a consistent linear scaling across all 256 coefficients (one shared nonzero factor) — which still catches genuine NTT bugs (wrong zeta, wrong butterfly index) while no longer emitting a misleadingMLKEM selftest: NTT roundtrip FAILEDline on stderr.
ML-KEM correctness end-to-end was never affected by either bug: the K-PKE roundtrip, the full KEM encaps/decaps roundtrip, the FIPS 203 roundtrip vectors (5 trials), and implicit-rejection all pass. The bugs were confined to the self-test's verification of an internal step and to how its result was reported.
Documentation accuracy
- README "13 NIST/RFC test vectors" → 16 (the true count); the security-results table AES/vector row updated to 16/16 pass.
Test status
test_vectors: 16 passed, 0 failed (was 14, of which the ML-KEM
self-test line was vacuous). Full suite 24/24 green, F-09 byte sweep
0/1827, F-06 0/2000. Strict GCC -Werror clean. Wire format
unchanged (v1.6).
Files touched
include/zupt.h (version 3.5.0 → 3.6.0)
doc/vaptvupt.1 (TH version 3.6.0)
tests/test_vectors.c (+AES-256-CTR SP 800-38A F.5.5/F.5.6; fix inverted self-test check; header comment)
src/zupt_mlkem.c (NTT roundtrip self-test: assert true Montgomery-scaled invariant)
README.md (vector count 13→16; table 14/14→16/16)
packaging/* (version 3.5.0 → 3.6.0; Debian + openSUSE changelog entries)
ROADMAP.md, AUDIT.md (3.6.0 entries)
[3.5.0] — 2026-06-01 — Measured constant-time MAC comparison (dudect)
Turns the codebase's most security-critical constant-time claim — the MAC tag comparison — from an asserted property into a measured one, and consolidates three duplicated inline compares into one audited primitive. Pure internal hardening; no wire-format change.
The gap
The codebase carried 20+ /* CT-REQUIRED */ markers but no timing
test verified any of them. The MAC tag compare is the one that matters
most: if "wrong on byte 0" finished measurably sooner than "wrong on
byte 31", an attacker could forge a tag byte-by-byte. That compare was
implemented as three separate inline byte-OR loops (the v1.6 strict
decrypt path, the v1.4/v1.5 legacy v2 candidate, and the F-08 archive-
integrity-trailer check) — duplicated, individually un-audited, and
never measured.
What changed
- One audited primitive.
int zupt_ct_memeq(const void *, const void *, size_t)inzupt_crypto.c: OR-accumulate with no early exit, read through avolatilesink so the optimiser cannot reintroduce a short-circuit or branch, branch-free 0/non-zero → 1/0 fold. The v1.6 strict decrypt path and the F-08 AIT check now both call it, so the property lives in exactly one place. (The v1.4/v1.5 legacy path keeps the formally-verified Jasminzupt_mac_verify_ct; its non-Jasmin C fallback and the carefully-tuned F-06 two-candidate fold are left intact.) - A dudect-style timing test (
tests/test_ct_timing.c, after Reparaz–Balasch–Verbauwhede, DATE 2017). It times the compare over two input classes — a fixed tag vs an identical copy (FIX) and vs a random tag (RND) — and applies Welch's t-test to the timing distributions. Built at -O2 (the shipped optimisation level), so it tests the code exactly as users run it, including that thevolatileaccumulator survives optimisation.
How the verdict is made honest
Absolute |t| thresholds are not portable — on a shared CI vCPU,
clock_gettime overhead and scheduler noise put even a perfectly
constant-time 32-byte compare at |t| in the low tens, while a dedicated
box sits near zero. So the criterion is environment-relative:
- A positive control times plain
memcmp(early-return, genuinely leaky) in the same environment and must show a clear leak (|t| in the hundreds–thousands), proving the harness is sensitive on this host. zupt_ct_memeq's data-dependent signal must be ≤ 20% of the control's. Measured here it lands at ~1% (e.g. control |t| ≈ 766, ct |t| ≈ 7, ratio ≈ 0.01) — i.e. statistically flat.- Results are the median of five runs to damp single-run noise. If
the host is too coarse for even
memcmpto show a leak, the test reports INCONCLUSIVE (exit 0) rather than passing vacuously.
A real regression — someone reintroducing an early return or a data-dependent branch in the compare — pushes the ratio toward 1.0 and fails the test.
Security
- The MAC tag comparison is now measured constant-time, not just annotated, with a regression guard in CI.
- One audited implementation replaces three inline copies, removing the risk that a future edit hardens one site and misses another.
- No change to authentication behaviour: F-09 byte sweep 0/1827, F-06 1-bit HMAC fuzz 0/2000, encrypted roundtrips and pre-3.5.0 archive decryption byte-exact, tamper still rejected.
Performance
Neutral — the compare does the same constant work; this is a correctness/security and maintainability change, not a throughput one.
Test status
24/24 suites green (new tests/test_ct_timing.sh, wired into
make check + make test). Strict GCC -Werror clean (AVX2 + scalar).
Wire format unchanged (v1.6).
Files touched
include/zupt.h (version 3.4.0 → 3.5.0; +zupt_ct_memeq decl)
doc/vaptvupt.1 (TH version 3.5.0)
src/zupt_crypto.c (+zupt_ct_memeq primitive; v1.6 strict path uses it)
src/zupt_format.c (F-08 AIT verify uses zupt_ct_memeq)
tests/test_ct_timing.c (NEW — dudect-style Welch t-test + memcmp control)
tests/test_ct_timing.sh (NEW — runner, -O2; in check + test)
Makefile (wire CT timing test into check + test)
packaging/* (version 3.4.0 → 3.5.0; Debian + openSUSE changelog entries)
README.md, ROADMAP.md, AUDIT.md (3.5.0 entries)
[3.4.0] — 2026-06-01 — F-15: Argon2id KDF parameter transparency
Makes the Argon2id password-encryption header self-describing about its key-derivation cost, closing a latent robustness/security gap for a long-lived archive format. Additive and back-compatible — existing archives decrypt unchanged.
The gap (F-15)
The PBKDF2 enc-header (0x01) records its iteration count, so a reader
always derives keys with the exact cost the writer used. The Argon2id
enc-header (0x04) recorded only [type | salt | nonce] (33 bytes) and
nothing about the KDF cost — it relied entirely on the libzuptsdk
"MODERATE" Argon2id preset reached through the opaque
zuptsdk_easy_derive_key. For a backup format meant to stay readable
for years that is a real problem: if the preset ever changed, archives
written under the old cost could become silently undecryptable, with
no field in the archive to tell a reader which cost to use.
(For the record, the explicit RFC 9106 zsdk_argon2id() with tunable
memory_kib/iterations/lanes is declared in the SDK headers but is
not exported by the vendored libzuptsdk.so — only
zuptsdk_easy_derive_key is callable — so the fix records the profile
in-band rather than re-parameterising the KDF.)
The fix
New Argon2id archives append a one-byte KDF profile descriptor at
offset 33 (ZUPT_ARGON2_PROFILE_MODERATE = 0x01), making the header
self-describing. Constants in zupt.h:
ZUPT_ARGON2_PROFILE_LEGACY 0x00 /* implicit: pre-3.4.0, no descriptor */
ZUPT_ARGON2_PROFILE_MODERATE 0x01 /* explicit: libzuptsdk MODERATE preset */
ZUPT_ARGON2_HDR_LEN_V1 33 /* [type|salt16|nonce16] */
ZUPT_ARGON2_HDR_LEN_V2 34 /* + [profile1] */
The descriptor sits inside the encryption header, which is covered by the v1.5+ archive-integrity trailer (F-08), so it cannot be stripped or forged without failing authentication.
Back-compatibility (additive, verified)
- The legacy reader checks
enc_hdr_len >= 33and reads fixed offsets, so it ignores the trailing byte. Existing 33-byte Argon2id archives decrypt byte-exact — verified against archives produced by 3.0.3 and earlier (plain and encrypted). - A 33-byte header (profile implicit) and a 34-byte header (profile explicit MODERATE) derive identical keys, so nothing about the key schedule changed; only the self-description was added.
- New readers validate the profile and refuse an unknown value (fail-closed) rather than guessing a derivation that would produce the wrong key.
Security
- Fail-closed on unknown KDF profile — an archive claiming an unsupported cost is rejected, not silently mis-derived.
- Tamper-evident — the descriptor is authenticated by the F-08 trailer (a flipped header byte fails decryption, verified).
- Build-time SDK-drift guard — the new test includes a coarse KDF cost floor (>= 20 ms) plus a determinism check, so a build against an SDK that has been swapped for a fast/weak Argon2id stand-in fails at test time instead of shipping under-protected archives.
- No change to authentication semantics: F-09 byte sweep 0/1827, F-06 1-bit HMAC fuzz 0/2000, constant-time tag compares unchanged.
Note on KDF performance (measured)
Profiling this release confirmed the password-mode cost is dominated by the one-time Argon2id KDF (~0.9–1.1 s), not the per-block pipeline: store-mode encrypt of a 1 MB input takes ~934 ms and a 40 MB input ~1245 ms, i.e. ~8 ms/MB (~125 MB/s) of actual per-block crypto after the 3.2.0 SHA-NI and 3.3.0 incremental-HMAC work. The KDF is intentionally memory-hard; it is not a target for speedups (faster = weaker). This release therefore invests in KDF transparency and robustness rather than KDF speed.
Test status
23/23 suites green (new tests/test_kdf_transparency.sh, 5 checks,
wired into make check + make test). Strict GCC -Werror clean (AVX2
- scalar). Wire format unchanged (v1.6).
Files touched
include/zupt.h (version 3.3.0 → 3.4.0; +ZUPT_ARGON2_PROFILE_* / HDR_LEN_*)
doc/vaptvupt.1 (TH version 3.4.0)
src/zupt_crypto_sdk.c (write profile descriptor; validate on read; fail-closed)
tests/test_kdf_transparency.c (NEW — F-15 header shape, back-compat, fail-closed, KDF guard)
tests/test_kdf_transparency.sh (NEW — runner; in check + test)
Makefile (wire KDF-transparency test into check + test)
packaging/* (version 3.3.0 → 3.4.0; Debian + openSUSE changelog entries)
README.md, ROADMAP.md, AUDIT.md (3.4.0 / F-15 entries)
[3.3.0] — 2026-06-01 — Incremental HMAC: drop per-block MAC malloc + copy
Removes a per-block heap allocation and full-payload copy from the Encrypt-then-MAC hot path on both the encrypt and decrypt sides, and folds the HMAC key-prefix once per keyring instead of once per block. No wire-format change — the MAC bytes are identical.
What was slow (structural)
Both zupt_encrypt_buffer_aad and zupt_decrypt_buffer_aad built the
HMAC input by malloc-ing a buffer sized
aad_extra + nonce + ciphertext + seq and memcpy-ing the entire
ciphertext into it, every block, only to feed zupt_hmac_sha256 once.
With the default 4 MB block size that is a 4 MB malloc plus a 4 MB copy
per block, per direction. Separately, zupt_hmac_sha256 recomputed
the ipad/opad key-prefix SHA-256 compression on every call even though
the per-block mac_key never changes.
What changed
- Incremental HMAC-SHA256 API (
zupt_hmac_ctx+zupt_hmac_sha256_init/update/final)._initfolds the ipad/opad key-prefix blocks once (one 64-byte compression each);_updatestreams message segments;_finalcloses the inner+outer hashes. The context is wiped on_final(it holds key-dependent state). - The one-shot
zupt_hmac_sha256is now a thin wrapper over the incremental API — single source of truth. The AIT and other once-per-archive MAC sites are unchanged in behaviour. - Both per-block MAC sites stream the segments (
aad_extra, thennonce || ciphertextdirectly from the output package, thenaad_seq) through the incremental HMAC. No concat buffer, no ciphertext copy, no per-block malloc/free. This covers the v1.6 strict-AAD path and the v1.4/v1.5 legacy-fallback v2 candidate; the v1 candidate was already a direct one-shot over the package.
Why it is wire-compatible (byte-identical MAC)
RFC 2104 defines HMAC(K,m) = H((K^opad) || H((K^ipad) || m)), and
SHA-256's Merkle-Damgard update() is associative over the message, so
streaming m in segments yields exactly the same tag as hashing one
concatenated buffer. This is not a heuristic — it is pinned by tests:
- RFC 4231 HMAC-SHA256 vectors pass (
test_vectors14/14). - New
tests/test_hmac_incremental.c: one-shot == incremental for single-segment; streamed 1/2/3-part splits == one-shot across lengths 0..100000; the exact per-block patternaad || nonce || ciphertext || seqstreamed in four updates == the concat one-shot; RFC 4231 TC2 known-answer. - Byte-exact decryption of archives produced by 3.2.0 and earlier (plain and encrypted, both Argon2id and PBKDF2 KDFs) — if the streamed MAC differed by a single byte, authentication would fail. It does not.
Security
- Identical authentication semantics. F-09 byte sweep: 0/1827
silent accepts. F-06 1-bit HMAC fuzz: 0/2000. Constant-time tag
comparisons (byte-OR accumulator / Jasmin
zupt_mac_verify_ct) are unchanged. - Less secret data on the heap. The old path copied the full
ciphertext into a second
malloc'd buffer per block; that buffer is gone, reducing the lifetime and footprint of sensitive data and the associatedzupt_secure_wipechurn.
Performance
Eliminates, per block per direction: one malloc of
~blocksize bytes, one memcpy of the full ciphertext, one
zupt_secure_wipe + free of that buffer, and (for the key prefix)
two redundant 64-byte SHA-256 compressions. The win scales with block
size and block count and stacks with the 3.2.0 SHA-NI work (fewer
SHA-256 invocations and faster ones). Not separately micro-benchmarked
in this release; it is a strict reduction in allocations and bytes
copied with no new work added.
Test status
22/22 suites green (new tests/test_hmac_incremental.sh, 4
assertions, wired into make check + make test). Strict GCC
-Werror clean (AVX2 + scalar). ASan clean on encrypted roundtrips for
both KDFs. Wire format unchanged (v1.6).
Files touched
include/zupt.h (version 3.2.0 → 3.3.0; +zupt_hmac_ctx + init/update/final)
doc/vaptvupt.1 (TH version 3.3.0)
src/zupt_crypto.c (incremental HMAC; one-shot wrapper; stream both per-block MAC sites)
tests/test_hmac_incremental.c (NEW — equivalence + RFC 4231)
tests/test_hmac_incremental.sh (NEW — runner; in check + test)
Makefile (wire incremental-HMAC test into check + test)
packaging/* (version 3.2.0 → 3.3.0; Debian + openSUSE changelog entries)
README.md, ROADMAP.md, AUDIT.md (3.3.0 entries)
[3.2.0] — 2026-06-01 — SHA-256 hardware acceleration (Intel SHA-NI)
Adds an SHA-NI hardware path for SHA-256, accelerating the part of the encrypted pipeline that measurement showed to be the bottleneck, and strengthening the side-channel posture of authentication. No wire-format change.
Why (measured, not assumed)
On this project's fixtures, store-mode (codec bypassed) compresses at
667 MB/s plain but only ~10 MB/s with a password. AES-NI is
already active (Jasmin 4-block CTR pipeline), so the cost is the
Encrypt-then-MAC second pass: HMAC-SHA256 in scalar C. Scalar
SHA-256 tops out around 150-250 MB/s, which is the wall. PBKDF2 (when
--kdf pbkdf2 is selected) is HMAC-SHA256 in a tight loop and is hit
even harder. SHA-256 is therefore the correct acceleration target.
What was added
src/zupt_sha256_shani.c— the FIPS 180-4 SHA-256 compression function using Intel SHA Extensions (SHA256RNDS2,SHA256MSG1,SHA256MSG2), processing multiple 64-byte blocks per call. This is the canonical Intel/Walton intrinsic sequence (the same one used by OpenSSL, BoringSSL, and the Linux kernel). Compiled with-msha -mssse3 -msse4.1on x86_64; a no-op translation unit on other architectures.- CPU detection:
has_shaniadded tozupt_cpu_features_t(CPUID.07H:EBX[29]). SHA-NI uses 128-bitxmmstate from the baseline x86-64 ABI, so unlike AVX it needs no XCR0/OSXSAVE gate. zupt_sha256_update()refactored to bulk-process full blocks: it drains any buffered partial, then feeds all full blocks to the hardware path in one call (zupt_sha256_transform_shani) whenzupt_cpu.has_shaniis set, else the scalar transform in a loop. The streaming/final()semantics are unchanged.
Security
SHA-NI is constant-time by construction: it performs no data-dependent memory accesses or branches, so it has a strictly stronger side-channel posture than any table- or branch-based software SHA-256. Since Zupt authenticates with HMAC-SHA256 over attacker-influenced ciphertext, a constant-time compression function is the right default wherever the CPU provides it. The scalar fallback is unchanged and remains the path on non-SHA-NI hardware.
Performance
On SHA-NI hardware (Intel Goldmont+/Ice Lake+, AMD Zen+), the SHA-256
compression function is [ESTIMATED] 3-8× faster than the scalar path
(per the public Intel SHA Extensions throughput figures; this is the
standard speedup OpenSSL/kernel report). This estimate is not measured
in this release — the CI/build host used for 3.2.0 has no SHA-NI
(sha_ni: 0), so the hardware path cannot be executed here. The number
will be replaced with a measured one once run on SHA-NI silicon. The
vaptvupt version command now prints the live hardware-acceleration set
for the running CPU (e.g. HW accel (this CPU): AES-NI SHA-NI AVX2(codec)).
Correctness validation (what was verified here)
- Round constants: all 64 SHA-NI K-schedule immediates are verified
bit-identical to the scalar
K[]table, in order — this eliminates the single most likely class of bug in a hand-written SHA-NI routine. - Scalar refactor: the rewritten
update()/sha256_blocks()passes the NIST FIPS 180-4 SHA-256 vectors (test_vectors14/14), proving the new buffering logic is sound on the path this host executes. - SHA-NI execution (on SHA-NI hardware only): the new
tests/test_sha256_shani.cchecks the SHA-NI path against the NIST "abc"/empty digests, multi-block == single-block-loop agreement (64B..64KiB), and streaming-split == one-shot (lengths 0..4096). On a host without SHA-NI it SKIPS these execution checks while the constant-equivalence, compile, and dispatch-wiring checks still gate the build.
Build / packaging
- New regression suite
tests/test_sha256_shani.sh(4 assertions) wired intomake checkandmake test. Total suites: 21/21 green. - Makefile:
SHANI_FLAGS = -msha -mssse3 -msse4.1on x86_64; dedicated compile rule forsrc/zupt_sha256_shani.o(excluded from the generic object rule to avoid a recipe-override warning); SHA-NI flags also threaded intotest-vectors,test-f06, andtest-asan. tests/test_static_analysis.shextended to hold the SHA-NI file to the same strict-Werror/-Wconversion -Wsign-conversionbar (9/9).- openSUSE OBS recipe renamed
zupt.spec/zupt.changes→vaptvupt.spec/vaptvupt.changes(Name: vaptvupt), withProvides: zupt/Obsoletes: zupt < 3.0.0so existing installs upgrade automatically; the binary still ships the/usr/bin/zuptcompatibility symlink and azupt.1man-page symlink._servicefilenameupdated tovaptvupt. cabelo's full changelog history is preserved.
Compatibility
- No wire-format change. Same SHA-256, same HMAC-SHA256, same Encrypt-then-MAC construction, same bytes on disk. Format stays v1.6; 3.1.x archives (plain and encrypted) extract unchanged.
- The dispatch is transparent: an archive made on a SHA-NI machine and one made on a scalar machine are byte-identical.
Files touched
include/zupt.h (version 3.1.0 → 3.2.0; +SHA-NI prototype)
include/zupt_cpuid.h (+has_shani field + ACSL)
doc/vaptvupt.1 (TH version 3.2.0)
src/zupt_cpuid.c (detect SHA-NI; 6-field struct init)
src/zupt_sha256.c (multi-block dispatch in update())
src/zupt_sha256_shani.c (NEW — SHA-NI compression function)
src/zupt_main.c (version: live HW-accel line)
Makefile (SHANI_FLAGS, dedicated rule, test wiring)
tests/test_sha256_shani.c (NEW — SHA-NI correctness)
tests/test_sha256_shani.sh (NEW — 4 assertions; in check + test)
tests/test_static_analysis.sh (hold SHA-NI file to strict bar)
tests/test_packaging_syntax.sh (openSUSE vaptvupt.* rename assertions)
packaging/opensuse/vaptvupt.spec (renamed from zupt.spec; Name: vaptvupt)
packaging/opensuse/vaptvupt.changes (renamed from zupt.changes)
packaging/opensuse/_service (filename → vaptvupt)
packaging/{aur,homebrew,nix,rpm}/* (version 3.1.0 → 3.2.0)
packaging/debian/changelog (3.2.0 entry)
README.md, ROADMAP.md, AUDIT.md (3.2.0 entries)
[3.1.0] — 2026-05-31 — VaptVupt codec 2.48.5 → 2.53.3 + decode over-copy fix
Integrates the upstream VaptVupt LZ + ANS codec from 2.48.5 to 2.53.3, and fixes a real heap-overflow in our decode wrapper that the newer codec's wider AVX2 hot path exposed.
Codec upgrade 2.48.5 → 2.53.3
The API surface is unchanged — include/vaptvupt.h, vaptvupt_api.h,
vv_ans.h, vv_huffman.h, and vv_platform.h are byte-identical
between 2.48.5 and 2.53.3. Only three .c files changed: vv_ans.c,
vv_decoder.c, vv_encoder.c. Three others (vv_huffman.c, vv_simd.c,
vv_xxh64.c) are byte-identical. Our wrapper (vaptvupt_api.c) needed
no signature changes.
What the 2.48.5 → 2.53.3 arc brings (from upstream CHANGELOG):
- v2.51.0 optimal parser (extreme mode): +3.0% aggregate ratio.
- v2.52.0 large-window extreme mode: +9.9% geomean, all 12 Silesia fixtures win.
- v2.52.1 fast-mode decode +21–43%, v2.52.2 fast-mode encode +7–12% (byte-identical output).
- v2.52.4 + v2.53.2: 6 corrupt-input decoder memory-safety fixes.
- v2.53.0
-w/--window: user-selectable window log (the tool does not expose this flag; the codec default is used). - v2.53.1/2/3: decode-speed micro-opts, all validated byte-identical.
Measured on our fixtures (10 MB each, single vCPU, vs the old 2.48.5 codec at L9):
| Fixture | 2.48.5 ratio | 2.53.3 ratio | Δ |
|---|---|---|---|
| text | 26.16% | 25.65% | −1.95% (smaller) |
| binary | 46.75% | 46.13% | −1.31% (smaller) |
| source | 4.72% | 4.50% | −4.72% (smaller) |
| random | 100.01% | 100.01% | ±0 (incompressible) |
| redundant | 0.0317% | 0.0723% | +128% (see note) |
Honest note on redundant: on a degenerate input (one 4.5 KB pattern
repeated to 10 MB), the new L9 is slightly larger (7584 B vs 3324 B,
still 0.07% of input) because large-window extreme mode optimizes for
real long-range matches, not a single repeated block. L5/L7 (6064 B)
beat L9 on this pathological case. This is a known tradeoff of
large-window mode, not a regression on realistic data.
Decode speed (measured, single vCPU, vs zstd-19, includes .zupt
envelope): text 278 MB/s (zstd 286), binary 300 (zstd 278), source 769
(zstd 625) — now roughly on par with zstd-19, up from 1.5–2× slower at
2.48.5. The previous "1.27× zstd-3 decode" claim (inherited from upstream
docs) has been removed from the help/version strings; it did not
reproduce in our own measurement and we cite measured numbers only.
F-14 (new): decode over-copy heap-overflow in our wrapper
ASAN flagged a heap-buffer-overflow WRITE of size 32 on
redundant.dat at L1, in the codec's AVX2 match_copy_32_hot →
_mm256_storeu_si256, writing 0 bytes past a 128 KB block buffer.
Root cause was ours, not the codec's: vaptvupt.h documents that the
SIMD copy helpers "may over-read/write by up to 32 bytes. Caller must
ensure sufficient slack in destination." Our decode buffers were
malloc(uncompressed_size) with zero slack. Codec 2.48.5 never
reached the over-copy on real inputs; 2.53.3's wider AVX2 hot path
(Sprint 53/58 decode-speed work) does.
Fix: a shared ZUPT_VV_DECODE_SLACK (64 B) guard. Every decode output
buffer is over-allocated by this margin and the padded capacity is
passed to the codec, so the over-copy always lands in owned memory. The
reported uncompressed size is unchanged; the slack is never part of the
output. Applied to both decode paths:
zupt_format.c(single-threadeddecompress_block)zupt_parallel.c(multi-threaded decode worker)
64 > 32 leaves margin for any future SIMD store-width increase (AVX-512 = 64 B stores).
Verified:
- ASAN single-threaded: 24/24 roundtrips clean across text/binary/source/ redundant/random + empty/1-byte/repetitive at L1/5/9.
- ASAN multi-threaded (
-t 4): 15/15 clean. - ASAN bit-flip fuzz: 300 trials, 0 crashes, 300 clean rejects.
vv_decoder.c: scalar build now -Werror clean (ZUPT-LOCAL)
Upstream's vv_decoder.c declares three safe-zone variables (ip_safe,
op_safe, max_valid_off) that are used only inside #if VV_INLINE_AVX2
blocks. On a scalar/non-AVX2 build (aarch64, our Termux target) they are
unused, producing three -Wunused-variable warnings that break a
-Werror build. Guarded the declarations with #if VV_INLINE_AVX2 so
the scalar build is -Werror clean. Byte-identical codegen for the AVX2
build. Marked ZUPT-LOCAL (3.1.0) so the next codec drop is easy to diff.
New regression test: tests/test_vv_decode_slack.sh
7 assertions: the ZUPT_VV_DECODE_SLACK constant exists and is ≥ 32;
both decode paths over-allocate and pass the padded capacity; the exact
ASAN-failing degenerate input round-trips byte-exact single-threaded
(L1/5/9) and multi-threaded (L1/9). Wired into make check and
make test.
Compatibility
- Wire format unchanged (v1.6). Archive magic unchanged.
- Back-compat verified: all archives written by the 2.48.5 build (3.0.3), including encrypted ones, extract byte-exact with the 2.53.3 build.
- Bidirectional: archives written by 3.1.0 extract byte-exact on re-read; the codec frame format is stable across 2.48.x↔2.53.x.
Test status
19/19 suites green (18 previous + new decode-slack suite). F-09 byte
sweep: 0/1827 silent accepts. F-06 HMAC fuzz: 0/2000. Strict GCC
-Werror clean (AVX2 and scalar). Our 9-file source clean under
-Wconversion -Wsign-conversion.
Files touched
include/zupt.h (version 3.0.3 → 3.1.0; +ZUPT_VV_DECODE_SLACK)
doc/vaptvupt.1 (codec 2.48.5 → 2.53.3; TH version 3.1.0)
src/vv_ans.c (codec 2.48.5 → 2.53.3)
src/vv_decoder.c (codec 2.48.5 → 2.53.3; +ZUPT-LOCAL scalar -Werror guard)
src/vv_encoder.c (codec 2.48.5 → 2.53.3)
src/vaptvupt_api.c (header comment 2.48.2 → 2.53.3)
src/zupt_format.c (F-14: decode buffer +slack, padded capacity)
src/zupt_parallel.c (F-14: parallel decode buffer +slack, padded capacity)
src/zupt_main.c (help/version codec string 2.53.3; removed unverified 1.27× claim)
Makefile (wire test_vv_decode_slack into make check + make test)
tests/test_vv_decode_slack.sh (NEW — 7 assertions)
README.md, ROADMAP.md, AUDIT.md (3.1.0 entries)
packaging/* (version 3.0.3 → 3.1.0)
Note: vv_huffman.c, vv_simd.c, vv_xxh64.c and all vv_*.h headers
are byte-identical to 2.48.5 and were left untouched.
[3.0.3] — 2026-05-26 — static-analysis cleanup + new regression test
A focused code-quality sprint that runs cppcheck + GCC's -Wconversion
on our own (non-vendored) C source for the first time, fixes three real
findings, and wires up a new regression test so they stay fixed.
cppcheck findings closed
| Finding | Class | Site | Fix |
|---|---|---|---|
(x&0x80) always true after preceding if(!(x&0x80))return n; |
knownConditionTrueFalse (dead AND) |
zupt_decode_varint |
Removed dead && (x&0x80); added invariant comment |
(c&0x80) always true after preceding terminator-byte return |
same | zupt_read_varint |
Reformatted for readability + same fix |
Both varint decoders had a defense-in-depth comment from years back
(Reject continuation past 64 bits — same defense as file variant) that
was correct in intent but encoded dead control flow. The check now reads
if (s>=64) return -1; with a comment documenting why it's safe to
drop the AND (the preceding early return is the precondition).
Behaviour is byte-identical. F-09 byte sweep still 0/1827.
-Wconversion / -Wsign-conversion findings closed
| Site | Issue | Fix |
|---|---|---|
zupt_main.c ECHO bit-clear |
~ECHO is int (negative); assigned to tcflag_t (unsigned int) |
Explicit (tcflag_t)~ECHO cast |
zupt_disk.c varint-return accumulation |
zupt_encode_varint returns int; accumulating into size_t |
Explicit (size_t) cast, matching the convention already used in zupt_format.c |
Our (non-vendored) C source now compiles cleanly under the union of the strict warning sets:
gcc -Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes \
-Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op \
-Wjump-misses-init -Wdouble-promotion -Woverlength-strings \
-Wconversion -Wsign-conversion -Werror
across nine source files: zupt_main.c, zupt_format.c, zupt_dedup.c,
zupt_disk.c, zupt_crypto.c, zupt_aes256.c, zupt_sha256.c,
zupt_xxh.c, zupt_parallel.c. Vendored vv_*.c, fips202.c, and
zupt_mlkem.c are kept under the upstream warning policy (they have
their own maintenance and the union flag set would generate many
false positives on standard library macros they use).
New regression test: tests/test_static_analysis.sh
7 assertions:
- Strict GCC +
-Werrorclean on all our source files. -Wconversion+-Wsign-conversionclean on all our source files.cppcheckwarning+performance level: 0 findings.cppchecknoknownConditionTrueFalsestyle findings on our code.cppcheckerror level: 0 findings.- Pattern check: varint decoders don't have the v3.0.2 dead-AND
pattern (
s>=64 && (x|c)&0x80) back. - Pattern check: ECHO bit-clear uses the explicit
(tcflag_t)cast.
Skips cppcheck assertions cleanly if cppcheck isn't installed on
the build host (some OBS / minimal chroots don't have it). Wired
into both make check and make test.
Test status
18/18 suites green (17 previous + new static-analysis suite). F-09 byte sweep: 0/1827 silent accepts. F-06 HMAC fuzz: 0/2000 silent accepts. Wire format unchanged at v1.6.
Why these matter (and why they don't)
The varint dead-AND wasn't a bug — the program behaved correctly. It was a code smell that survived multiple sprints because no static analyser was running over the source. Adding the analyser to the regression suite is what changes; the specific fixes are trivial individually.
The -Wconversion casts are also not bug fixes. They're
intent-documentation: instead of relying on the compiler's
"unsigned conversion of a negative int" silent behaviour, we now
state the cast explicitly. Any future contributor reading
new_t.c_lflag &= (tcflag_t)~ECHO sees the conversion immediately;
without the cast, they'd have to verify the conversion was safe.
Files touched
include/zupt.h (version 3.0.2 → 3.0.3)
doc/vaptvupt.1 (TH version 3.0.2 → 3.0.3)
src/zupt_format.c (varint decoders: remove dead && (x&0x80); add invariant comment)
src/zupt_main.c (ECHO bit-clear: explicit (tcflag_t) cast)
src/zupt_disk.c (varint return: explicit (size_t) cast matching convention)
Makefile (wire test_static_analysis into make check + make test)
tests/test_static_analysis.sh (NEW — 7 assertions)
packaging/aur/PKGBUILD (pkgver 3.0.3)
packaging/debian/changelog (3.0.3-1 prepended)
packaging/rpm/vaptvupt.spec (Version 3.0.3)
packaging/homebrew/vaptvupt.rb (version 3.0.3)
packaging/nix/flake.nix (version 3.0.3)
packaging/opensuse/{zupt.spec,_service,zupt.changes} (3.0.3 + cabelo entry prepended)
CHANGELOG.md (this entry)
ROADMAP.md (3.0.3 row)
AUDIT.md (3.0.3 history entry)
[3.0.2] — 2026-05-26 — F-13 closed (usage() literal size) + help-text cleanup
One real warning closed, two real bits of stale text in vaptvupt help,
one new compile-time guard, one new regression test.
F-13: usage() string literal exceeded C99's 4095-char limit
src/zupt_main.c's usage() had a single fprintf with adjacent
string literals totalling 4121 chars, triggering
-Woverlength-strings on strict builds (C99 §5.2.4.1 requires
compilers to support strings up to 4095 chars only; longer is
implementation-defined). GCC and clang both compile it fine in
practice, but the warning is real and the literal was a sign the
function had grown without architectural review.
Refactored into five logical fprintf sections (synopsis, compress options, extract/list/test options, examples, footer). Each section is now < 1500 chars; the worst is the compress-options block at ~1470 chars. Easier to read, easier to maintain, and the warning is gone.
-Woverlength-strings added to the default CFLAGS so future
regressions fail the build under -Werror.
Help-text drift cleanup
While fixing F-13 we found three pieces of stale content:
| Stale | Now |
|---|---|
zupt compress / zupt extract in all examples |
vaptvupt compress / vaptvupt extract (12 example lines) |
| "Compression: LZ77 (1MB window) + Huffman entropy coding" | "Default codec: VaptVupt LZ + ANS 2.48.5 (AVX2/NEON SIMD, 1.27x zstd-3 decode)" |
| "License: AGPL-3.0-or-later (Zupt) + ..." | "License: AGPL-3.0-or-later (VaptVupt) + ..." |
Also added:
- Format-version line:
Format: v1.6 (since v2.3.1); archives byte-compatible with v2.3.1+ - Dual-licensing visibility:
Dual-licensed: commercial license available: sac@securityops.co
New regression test: tests/test_help_consistency.sh
10 assertions covering everything we just fixed:
- Python helper walks
src/zupt_main.c's fprintf calls, computes the concatenated literal size, and asserts the worst case is < 4095 chars (F-13 byte-level guard). - Help output has at least one
vaptvupt <subcommand>example. - Help output has zero bare
zupt <subcommand>example lines (the legacy command name in raw examples is the drift we just fixed). - Help mentions "VaptVupt LZ + ANS" as the default codec.
- Help does not have the stale "LZ77 (1MB window) + Huffman entropy coding" description.
- Help shows "AGPL-3.0-or-later (VaptVupt)" as the license.
- Help shows the commercial-licensing contact.
- Help identifies Argon2id as the default KDF.
- Help reports format version v1.6.
vaptvupt helpexits with status 0.
Wired into both make check (distro-safe) and make test (full).
Test status
17/17 suites green (16 previous + new help-consistency suite). F-09 byte sweep: 0/1827 silent accepts. F-06 HMAC fuzz: 0/2000 silent accepts. Format unchanged at v1.6.
Strict-build check: gcc -Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op -Wjump-misses-init -Wdouble-promotion -Woverlength-strings -Werror -O2 -std=c11 — clean.
Files touched
include/zupt.h (version 3.0.1 → 3.0.2)
doc/vaptvupt.1 (TH version 3.0.1 → 3.0.2)
src/zupt_main.c (usage() split into 5 sections; legacy `zupt` → `vaptvupt` in examples; codec/license refreshed)
Makefile (CFLAGS gains -Woverlength-strings; wire test_help_consistency into make check + make test)
tests/run_quick.sh (help-line regex accepts vaptvupt|zupt)
tests/test_help_consistency.sh (NEW — 10 assertions)
packaging/aur/PKGBUILD (pkgver 3.0.2)
packaging/debian/changelog (3.0.2-1 prepended)
packaging/rpm/vaptvupt.spec (Version 3.0.2)
packaging/homebrew/vaptvupt.rb (version 3.0.2)
packaging/nix/flake.nix (version 3.0.2)
packaging/opensuse/{zupt.spec,_service,zupt.changes} (3.0.2 + cabelo entry prepended)
CHANGELOG.md (this entry)
ROADMAP.md (3.0.2 row)
AUDIT.md (F-13 closed; 3.0.2 history entry)
[3.0.1] — 2026-05-26 — GUI license + version-parsing cleanup
Two real bugs in v3.0.0's GUI and a third in gui/LICENSE-GUI, plus a
new regression test that catches them.
MIT reference removed from GUI
The v3.0.0 GUI's about panel had a credit line:
zupt Cristian Cezar Moises MIT
That was false. The GUI's SPDX header has always been
AGPL-3.0-or-later, the top-level LICENSE is AGPL, and the project
policy is AGPL-3.0-or-later with commercial dual-licensing forever.
The MIT line was a templating mistake inherited from an early scaffold.
Removed. The CREDITS block now has two correctly-attributed rows:
- VaptVupt application — AGPL-3.0-or-later (commercial license available) —
git.securityops.co/cristiancmoises/zupt - VaptVupt LZ + ANS codec — GPL-3.0-or-later (commercial license available) —
git.securityops.co/cristiancmoises/vaptvupt
Both rows carry the commercial-licensing contact sac@securityops.co.
gui/LICENSE-GUI was an actual MIT license file. Replaced with the
AGPL-3.0-or-later text + commercial-dual-licensing note + a historical
note explaining the prior MIT mistake (so anyone with an old tarball
can't legitimately claim to have received an MIT grant).
Top-level LICENSE preamble updated to reflect the Zupt → VaptVupt
rename.
GUI version-string parsing bug
v3.0.0's GUI parsed the CLI version banner with
ZUPT_VER_SHORT.replace("zupt ", ""). With v2.4.x the banner was
literally zupt 2.4.8 so this kind of worked. With v3.0.0 the banner
became:
vaptvupt 3.0.0 (formerly zupt; renamed in v3.0.0 — INPI Brasil trademark)
The substring "zupt " ALSO appears inside "formerly zupt; renamed",
so replace chewed up the wrong substring. Window title, splash
header, status bar and about-panel hero number all displayed the
entire 75-character string instead of just "3.0.0".
Fixed with a strict anchored regex:
_VERSION_RE = re.compile(r'^(?:vaptvupt|zupt)\s+(\d+\.\d+\.\d+(?:[._A-Za-z0-9-]*)?)')
Now _get_version() returns three values:
ZUPT_VER_SHORT— full first line (used as fallback display)ZUPT_VER_NUMBER— just the version number ("3.0.1")ZUPT_VER_FULL— entire stdout (used in the about panel)
All call sites updated.
GUI about-panel enhancement
- Header
"ZUPT"→"VAPTVUPT"(matches the rename). - Splash header same change.
- Crypto-stack table expanded to reflect the v2.4.1+ defaults:
- Argon2id (RFC 9106) — listed as default KDF since v2.4.1
- PBKDF2 — relabeled as legacy /
--kdf pbkdf2fallback - HKDF (RFC 5869) — used in the post-quantum hybrid combiner
- XXH64 — labeled as non-crypto, used only inside the AEAD envelope
- New "COMPRESSION CODEC" section with the VaptVupt LZ + ANS 2.48.5 attribution.
- Trademark rename note visible.
New regression test: tests/test_gui_branding.sh
11 assertions covering exactly the bugs we just fixed:
- No MIT references in the GUI source (excluding the explanatory comment)
gui/LICENSE-GUIis AGPL-licensed and does not start with "MIT License"- GUI source SPDX header is
AGPL-3.0-or-later - No
replace("zupt ", ...)parser in code - An anchored
_VERSION_REregex is present - Headers say
VAPTVUPT(notZUPT) - Crypto stack mentions Argon2id and the VaptVupt codec
- Commercial-licensing contact is visible
- End-to-end functional check: regex extracts the version that matches
include/zupt.h
Wired into both make check (distro-safe) and make test (full).
Test suite status
make test: all 16 suites green (15 previously + 1 new branding suite).
F-09 byte sweep: 0/1827 silent accepts. F-06 HMAC fuzz: 0/2000 silent accepts.
Files touched
include/zupt.h (version 3.0.0 → 3.0.1)
doc/vaptvupt.1 (TH version 3.0.0 → 3.0.1)
gui/src/zupt_gui.py (anchored _VERSION_RE; about-panel rewrite; window/status compact display; no more replace("zupt ",...))
gui/LICENSE-GUI (MIT → AGPL-3.0-or-later with historical note)
LICENSE (preamble updated for Zupt → VaptVupt rename)
tests/test_gui_branding.sh (NEW — 11 assertions)
Makefile (wire test_gui_branding into both `make check` and `make test`)
packaging/aur/PKGBUILD (pkgver 3.0.1)
packaging/debian/changelog (3.0.1-1 prepended)
packaging/rpm/vaptvupt.spec (Version 3.0.1)
packaging/homebrew/vaptvupt.rb (version 3.0.1)
packaging/nix/flake.nix (version 3.0.1)
packaging/opensuse/{zupt.spec,_service,zupt.changes} (3.0.1 + cabelo changelog entry)
CHANGELOG.md (this entry)
ROADMAP.md (3.0.1 row)
AUDIT.md (3.0.1 history entry)
[3.0.0] — 2026-05-25 — VaptVupt rename + VV codec 2.48.5 + GUI bug fix
Major version. INPI Brasil trademark rename + integrated codec upgrade + GUI discovery bug fix + enhanced documentation + measured performance and security results.
Rename: Zupt → VaptVupt
A prior INPI Brasil trademark registration on "Zupt" for unrelated software forced a product rename. The change is intentionally narrow:
- What changes: the binary name (
vaptvupt), the brand string in the banner/help/version output, package names (vaptvuptin Debian/RPM/AUR/Nix/Homebrew/openSUSE), and user-visible strings in the GUI. - What does NOT change: the archive extension (still
.zupt), the header magic bytes (still\x5A\x55\x50\x54\x1A\x00= "ZUPT"), the C identifier prefix (stillzupt_/ZUPT_for ABI continuity with libzuptsdk), the on-disk format (still v1.6 since v2.3.1). - Verified bidirectional compatibility: archives produced by v2.4.8 extract byte-exact under v3.0.0 and vice versa.
- Legacy symlink:
/usr/bin/zupt → /usr/bin/vaptvuptis installed by the Makefile and shipped in all distro packages for one major version cycle. Existing scripts, shell history, and cron jobs keep working without modification.
Integrated VaptVupt LZ + ANS codec 2.48.5
Two real bugfixes (both fuzzer-found by upstream's libFuzzer harness in Sprint 23):
- heap-buffer-overflow READ in
vv_dstream_decompress_chunk(medium severity).csz - 1underflowedsize_ttoSIZE_MAXwhencsz == 0, causing the entropy decoder to read past the input buffer (default 65 536 bytes). Fixed by porting the stateless-decoder's existing check to the streaming path. - UBSan-safe pointer arithmetic in
vv_copy_match. The originaldst[i - (ptrdiff_t)offset]formed an intermediate pointer with a negative offset on the first iteration; even though the resulting address was always in-bounds (caller-validated), UBSan's pointer-bounds check flagged it. Hoisteddst - offsetinto a named pointer outside the loop where it lands in valid memory. const-correctness cleanup in the entropy encoder (vv_ans.c): threeconst uint8_t *declarations narrowed to non-const matching the actual write semantics in two safe-zone branches.
API surface unchanged — headers are byte-identical between 2.48.2 and 2.48.5.
Fixed: GUI binary-discovery bug
Reported: vaptvupt-gui (then zupt-gui) launched from a desktop
session couldn't find the zupt binary in /usr/bin; manually
copying it to /usr/local/bin worked around the problem.
Root cause: desktop sessions on some distros launch GUI apps with a
minimal PATH (e.g. /usr/local/bin:/usr/local/sbin) that omits
/usr/bin. shutil.which("zupt") then returns None. The old
fallback list relied on is_file() only — no liveness check, no
executable check, no logging.
New _find_vaptvupt():
- Tries env vars
VAPTVUPT_BINand legacyZUPT_BINfirst. - Walks the source tree (handles "run from source checkout"); tries
both
vaptvuptandzuptnames. shutil.which()on both names.- Hard-coded common paths:
/usr/local/bin,/usr/bin,/opt/vaptvupt/bin,/opt/homebrew/bin, Termux's Android path, Flatpak's/app/bin, plus the legacyzuptequivalents. - Liveness check on every candidate: runs
version, 3-second timeout, must exit 0. Catches missing shared libraries, broken rpath, ABI mismatch. - Discovery log to stderr when
VAPTVUPT_DEBUG=1orZUPT_DEBUG=1. Tells the user exactly which path was tried and why each failed.
Enhanced man page (597 lines, was 422)
Full rewrite. New sections:
- POST-QUANTUM ENCRYPTION — explicit derivation of the hybrid session key from ML-KEM-768 + X25519, key-commitment notes, the Jasmin/C constant-time policy by architecture.
- PERFORMANCE — measured numbers (table) with honest reading of what they mean.
- SECURITY / Threat model — what VaptVupt protects against AND what it explicitly does NOT (compromised endpoint, weak password, metadata leakage, CRIME/BREACH-style side channels, DoS by very large input).
- ENVIRONMENT —
VAPTVUPT_BIN,VAPTVUPT_DEBUG, legacyZUPT_BINaliases. - EXIT STATUS — 0–5 documented with semantics.
Old doc/zupt.1 is now a symlink to doc/vaptvupt.1; the install
rule emits both vaptvupt.1.gz and a zupt.1.gz → vaptvupt.1.gz
symlink so man zupt keeps working.
Performance and security tests run for this release
See README.md's "Benchmark Results (v3.0.0 release)" and "Security Test Results (v3.0.0 release)" sections — those are the canonical v3.0.0 numbers, replacing the v2.4.x tables per the user-specified "every new version replaces the README's ultimate tests" policy.
Quick summary:
- Benchmark vs gzip-9 / zstd-3 / zstd-19 on four fixtures. On binary-struct data VaptVupt L9 beats both gzip-9 (44.7% vs 52.0%) and zstd-3 (44.7% vs 77.3%) on ratio. Encode throughput is the weak axis (~7–11 MB/s at L9 vs zstd-3's ~100–400 MB/s).
- Security regression — 0/1827 silent accepts on F-09 byte sweep, 2000/2000 honest roundtrips on F-06 HMAC fuzz with 0 silent tamper accepts. 91/91 distro-safe assertions pass.
Other fixes
Makefileproduces a./zuptsymlink alongside./vaptvuptso all 27 existing test files keep working unmodified.tests/test_completions_manpage.shupdated to accept either product name in the regression patterns.tests/test_packaging_syntax.shupdated for the rename (recipes can bevaptvuptor legacyzuptnamed).- All packaging recipes (AUR PKGBUILD, Debian source package,
Homebrew formula, Nix flake, Fedora/RPM spec, openSUSE OBS files)
renamed and updated to v3.0.0 with
Provides: zupt/Obsoletes: zupt < 3.0.0/ equivalents for clean upgrade.
Files touched
include/zupt.h (version 2.4.8 → 3.0.0; ZUPT_PRODUCT_NAME macros added)
src/zupt_main.c (banner, usage, version subcommand)
gui/src/zupt_gui.py (new _find_vaptvupt with liveness checks + discovery log)
src/vv_ans.c, vv_decoder.c, vv_encoder.c, vv_huffman.c, vv_simd.c, vv_xxh64.c (VV 2.48.5)
include/vaptvupt.h, vaptvupt_api.h, vv_ans.h, vv_huffman.h, vv_platform.h (VV 2.48.5; byte-identical)
doc/vaptvupt.1 (NEW — 597 line man page)
doc/zupt.1 (now a symlink to vaptvupt.1)
completions/vaptvupt.bash (renamed from completions/zupt.bash; both names registered)
completions/_vaptvupt (renamed from completions/_zupt; both names #compdef)
completions/vaptvupt.fish (renamed from completions/zupt.fish; both names complete -c)
Makefile (TARGET=vaptvupt; LEGACY_LINK=zupt; install rule emits symlinks)
tests/test_completions_manpage.sh (regex patterns accept both names)
tests/test_packaging_syntax.sh (regex patterns accept both names)
packaging/aur/PKGBUILD (pkgname=vaptvupt; provides/replaces/conflicts zupt; v3.0.0)
packaging/debian/{control,changelog} (Source/Package vaptvupt; Provides/Replaces/Conflicts zupt; v3.0.0)
packaging/rpm/vaptvupt.spec (renamed from zupt.spec; Name vaptvupt; Provides/Obsoletes/Conflicts zupt; v3.0.0)
packaging/homebrew/vaptvupt.rb (renamed from zupt.rb; class Vaptvupt; v3.0.0)
packaging/nix/flake.nix (pname vaptvupt; v3.0.0)
packaging/opensuse/{_service,zupt.spec,zupt.changes} (v3.0.0; prepended v3.0.0 entry to cabelo's history)
README.md (rename header; PERFORMANCE + SECURITY tables replaced)
CHANGELOG.md (this entry)
ROADMAP.md (3.0.0 row)
AUDIT.md (v3.0.0 history entry)
[2.4.8] — 2026-05-24 — make check + cabelo's openSUSE update + binary packages
Distro-friendly release. Adds a curated make check target for OBS /
Debian / RPM %check sections, rewrites the openSUSE OBS files to
match cabelo's existing style with two important bugfixes (license
and version), and ships 8 binary packages (CLI .deb/.rpm/AppImage,
GUI .deb/.rpm/AppImage, source tarball, fallback AppDir).
make check — distro-safe test subset
Targeted at downstream packagers (openSUSE OBS, Debian, Fedora) who
need a %check / override_dh_auto_test target that:
- Runs in <2 minutes (not the full byte-sweep arc that
make testdoes) - Doesn't call
make cleanmid-stream (rules outtest_dist_reproducible.sh) - Doesn't depend on tools that may be absent in the build chroot
(no PyYAML, no ruby, no
dpkg-parsechangelog) - Doesn't depend on multi-threading that's flaky under emulation
(skips
test_threaded.shandtest_pq.sh's MT subset) - Does cover the security-critical regressions: F-06 HMAC, F-08 AIT, F-09 byte-level integrity, F-10 KDF default, F-11 auth-fail wording, F-12 comments
- Does verify cryptographic primitives against NIST/RFC vectors
Total: ~91 assertions across 10 suites. Recommended for all OBS
%check sections.
openSUSE OBS files for home:cabelo:innovators/zupt
cabelo currently ships zupt 1.5.5 on OBS with two bugs:
-
License field says
MIT— wrong. Upstream isAGPL-3.0-or-later(dual-licensed AGPL + commercial). Fixed. -
%checkcallstest-all— that target includes threading tests which are flaky on emulated OBS build hosts (false positives observed in this sprint's verification matrix). Switched tomake check(the new distro-safe target).
The updated files at packaging/opensuse/ keep cabelo's existing
conventions intact:
- Still uses
tar_scmservice (not the newerobs_scm) - Still pulls from GitHub (
https://github.com/cristiancmoises/zupt) - Still
%autosetup -p1+chmod +x tests/*.sh - Still
V=1 ... CFLAGS=... LDFLAGS=... LDLIBS=-lm -lpthreadbuild - Still
%ifarch s390xbranch in%check - Still minimal
BuildRequires: gcc gzip(plus addedmakefor newer chroots)
zupt.changes is prepended with 13 new entries covering
2.0.0 → 2.4.8; cabelo's existing 1.0.0–1.5.4 history is preserved
verbatim.
Binary packages built and shipped
All 8 produced from this v2.4.8 source tree, smoke-verified to run:
| File | Size | Verified |
|---|---|---|
zupt_2.4.8_amd64.deb |
412 KB | dpkg-deb metadata clean; binary runs |
zupt-2.4.8-1.x86_64.rpm |
499 KB | rpm -qpi clean; License: AGPL-3.0-or-later AND GPL-3.0-or-later |
zupt-2.4.8-x86_64.AppImage |
1.3 MB | --appimage-extract-and-run version works |
zupt-2.4.8-x86_64.AppDir.tar.gz |
391 KB | FUSE-less fallback; AppRun works |
zupt-gui_1.1.1_all.deb |
65 KB | dpkg-deb metadata clean |
zupt-gui-1.1.1-1.noarch.rpm |
27 KB | rpm metadata clean |
Zupt-GUI-1.1.1-x86_64.AppImage |
961 KB | built |
Zupt-GUI-1.1.1-x86_64.AppDir.tar.gz |
15 KB | built |
Plus the reproducible source tarball:
| zupt-2.4.8.tar.gz | 829 KB | sha256: 2289e8dbbc8746727dd22102117fd367f3d58f3e9f914acf12f54f2ac654f0eb |
packaging/build-dmg.sh — honest macOS .dmg builder
New script. macOS-only because hdiutil only exists on Darwin.
Refuses to run on Linux with a helpful message pointing to the
AppImage / .deb / .rpm / Homebrew formula. Handles:
- Universal binary build (
-arch arm64 -arch x86_64) when run on Apple Silicon hosts .appbundle with properInfo.plist- Optional code-signing via
APPLE_DEV_IDenv var - Optional notarization via
APPLE_NOTARIZE_KEYenv var - Drag-to-install
.commandhelper inside the .dmg
Other fixes
packaging/build-gui-rpm.shnow passes--nodepsto rpmbuild, which is necessary on Debian/Ubuntu hosts wherepython3isn't registered as an RPM. Runtime deps still apply on install.doc/zupt.1.THversion bumped to 2.4.8 to matchinclude/zupt.h(caught bytests/test_completions_manpage.sh).tests/test_packaging_syntax.shexpanded: 22 → 27 assertions, adds openSUSE OBS validation (zupt.spec,zupt.changes,_service).
What didn't change
- No source-code changes in
src/orinclude/except the version bump - Archive format still v1.6 — archives byte-identical to v2.4.3
- All 12 findings (F-01..F-12) remain closed; no new findings opened
Verification
makeclean on plain GCC + Clangmakestrict GCC + strict Clang — cleanmake check— all 10 suites, 91 assertions greenmake test— all 15 suites greenmake audit-licenses— cleanmake distreproducibility — byte-identical sha256- All 8 binary packages built and smoke-tested
rpm --specfile packaging/opensuse/zupt.specparses cleanxml.etreevalidatespackaging/opensuse/_serviceas well-formed XML
Files touched
include/zupt.h (version 2.4.7 → 2.4.8)
doc/zupt.1 (.TH version bump)
Makefile (new `check` target; .PHONY)
packaging/opensuse/_service (NEW — tar_scm pointing at v2.4.8 GitHub tag)
packaging/opensuse/zupt.spec (NEW — minimal spec matching cabelo's style)
packaging/opensuse/zupt.changes (NEW — 13 entries prepended to cabelo's history)
packaging/opensuse/README.md (NEW — osc submission guide)
packaging/build-dmg.sh (NEW — macOS-only .dmg builder)
packaging/build-gui-rpm.sh (--nodeps for Debian/Ubuntu build hosts)
tests/test_packaging_syntax.sh (22 → 27 assertions; openSUSE validation)
packaging/aur/PKGBUILD (pkgver 2.4.8)
packaging/debian/changelog (top entry 2.4.8-1)
packaging/rpm/zupt.spec (Version: 2.4.8)
packaging/homebrew/zupt.rb (version 2.4.8)
packaging/nix/flake.nix (version 2.4.8)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.8 row)
AUDIT.md (history entry)
[2.4.7] — 2026-05-20 — manpage refresh + shell completions
UX/documentation release. Three additions and three small banner corrections; no behavioural changes outside the version bump and the three "stale KDF" string fixes.
Manpage rewritten (doc/zupt.1)
The prior manpage (368 lines) predated all v2.4.x features. Stale content removed; rewritten from scratch (422 lines) to cover:
- All current subcommands (
compress,extract,list,test,info,bench,disk backup,disk restore,keygen,version,help) - v2.4.x options (
--kdf,-c/--comment,--comment-file,--pq-sdk) with concrete usage notes - F-11 generic auth-fail message + the
--verboseescape hatch - F-12 encrypted archive comments and their MAC-coverage properties
- Cryptographic primitives the binary uses (FIPS 180-4, 202, 203; RFC 7748, 6070, 4231; NIST SP 800-38A)
- Examples for the four most common workflows (Argon2id password, PQ-SDK key, tamper-rejection demo, disk backup)
- A
SEE ALSOcross-reference totar(1),gzip(1),xz(1),age(1),openssl(1) - Reference to
THREAT_MODEL.mdfor security-boundary documentation
Shell completions (new completions/ directory)
| File | Shell |
|---|---|
completions/zupt.bash |
Bash 4.x+ (uses _init_completion with a manual fallback for hosts without bash-completion installed) |
completions/_zupt |
zsh (uses _describe + _values + _arguments) |
completions/zupt.fish |
fish 3.x+ (subcommand-aware via __fish_zupt_using_subcommand predicate) |
Each file covers every CLI flag the binary actually parses (16
critical flags including --kdf, --comment, --comment-file,
--pq, --pq-sdk, --dedup, --solid, --verbose, --quiet,
--threads, --level, --block, --store, --fast, --lzhp,
--vaptvupt). Argument suggestions:
--kdf→argon2id pbkdf2--level→1 2 3 4 5 6 7 8 9--threads→0 1 2 4 8 16 32-o/--output→ directories only--comment-file,--pq*→ file paths- Archive positional arguments →
*.zuptfiles
Banner corrections (three minor)
The help banner, help footer, and version subcommand output all
still claimed KDF: PBKDF2-SHA256 despite Argon2id being the
default since v2.4.1. All three sites corrected:
Before: Encryption: AES-256-CTR + HMAC-SHA256 | KDF: PBKDF2-SHA256
After: Encryption: AES-256-CTR + HMAC-SHA256 | KDF: Argon2id (default) / PBKDF2 (--kdf pbkdf2)
This was a v2.4.1 oversight; user-visible output is now consistent with actual behaviour.
make install wires the new files
$PREFIX/share/bash-completion/completions/zupt
$PREFIX/share/zsh/site-functions/_zupt
$PREFIX/share/fish/vendor_completions.d/zupt.fish
make uninstall removes them. Distros can override paths in
their make install invocation; the recipe uses standard
location conventions per the Filesystem Hierarchy Standard.
New regression test
tests/test_completions_manpage.sh — 12 assertions wired into
make test:
- Bash completion:
bash -nsyntax clean, defines_zupt, registers viacomplete -F - zsh completion:
zsh -nsyntax clean, has#compdef zupt - fish completion: has
complete -c zuptentries (fullfish -ncheck skipped on hosts without fish; CI installs fish) - All three files cover the 16 critical flags (fish via
-l flagform, others via--flagform) - Manpage mentions all v2.4.x features (
--kdf,--comment, Argon2id, F-11 wording,--comment-file,--pq-sdk, ML-KEM-768) - Manpage has required
.SHsections (NAME, SYNOPSIS, DESCRIPTION, COMMANDS, EXAMPLES) - TH header version matches
include/zupt.h grofflint check when available (skipped on hosts without it)
Verification
makeclean on plain GCC + Clang.makestrict GCC + Clang — clean.make test— all 13 suites green (completions+manpage 12/12).make audit-licenses— clean.make distreproducibility — byte-identical sha256.make DESTDIR=/tmp/install-test PREFIX=/usr installverified: binary, gzipped manpage, and all 3 completion files placed at correct paths under/tmp/install-test/usr/.
Honest scope note (consistent with v2.4.6's note)
This sprint is genuinely one-session-finishable. The harder candidates remain:
- ML-DSA-87: still multi-sprint, still requires vendoring PQClean
- Jasmin re-wiring: still requires a
jasminc-equipped environment
When the engineering arc resumes one of those, this sprint's infrastructure (manpage, completions, install paths) will already be in place to advertise the new functionality.
Files touched
include/zupt.h (version 2.4.6 → 2.4.7)
src/zupt_main.c (3 stale KDF banner strings)
doc/zupt.1 (rewritten, 368 → 422 lines)
completions/zupt.bash (new)
completions/_zupt (new)
completions/zupt.fish (new)
tests/test_completions_manpage.sh (new, 12 assertions)
Makefile (install + uninstall add
completions; test target adds
completions+manpage check)
DISTRIBUTION.md (completions section added)
packaging/aur/PKGBUILD (pkgver 2.4.7)
packaging/debian/changelog (top entry 2.4.7-1)
packaging/rpm/zupt.spec (Version: 2.4.7)
packaging/homebrew/zupt.rb (version 2.4.7)
packaging/nix/flake.nix (version 2.4.7)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.7 row)
AUDIT.md (history entry)
[2.4.6] — 2026-05-20 — CI rewrite + THREAT_MODEL.md
Non-security release. Continues the documentation/infrastructure arc started in v2.4.4. No source-code changes outside the version bump and packaging-syntax test expansion; archive format unchanged.
.github/workflows/ci.yml — 8-job CI matrix
Replaces the prior 4-job CI with a comprehensive matrix that mirrors
the project's local-verification protocol from PROMPT.md §6:
| Job | What it does |
|---|---|
build-and-test |
Plain make + make test + make audit-licenses on both GCC and Clang (matrix strategy) |
strict-warnings |
Builds with -Werror + the full §6 warning set (-Wshadow, -Wcast-align, -Wstrict-prototypes, -Wnull-dereference, etc.) on both GCC and Clang |
sanitizers |
make test-asan + a --pq-sdk byte-exact roundtrip on include/ under ASAN/UBSAN |
pie-hardening |
Builds with -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2, verifies binary is PIE, smoke-tests an encrypted roundtrip |
cross-aarch64 |
Runs make && make test inside an aarch64 Ubuntu container via QEMU emulation |
dist-reproducibility |
Runs make dist twice, asserts byte-identical sha256, uploads tarball as workflow artifact |
packaging-syntax |
Runs tests/test_packaging_syntax.sh with ruby, dpkg-dev, and rpm installed so all five recipe validators actually run |
release |
Conditional on refs/tags/v*. Verifies tag matches include/zupt.h, builds reproducible tarball, creates GitHub release with sha256 sidecar |
The release job's tag check is important: pushing a v2.4.7 git
tag when include/zupt.h still says 2.4.6 fails the workflow
before any release is published.
THREAT_MODEL.md — 12 KB plain-English security boundary doc
Per userPreferences: "threat model in plain English. State
explicitly what the system does NOT protect against."
Covers, with appropriate plain-language honesty:
- What Zupt protects against: archive confidentiality
(encrypted modes), byte-level tamper detection (0 silent accepts
in v1.6 sweep), authentication-failure indistinguishability
(F-11), post-quantum forward secrecy in
--pq-sdk, side-channel resistance on Jasmin-proven hot paths - What Zupt does NOT protect against: compromised endpoints,
key compromise (no forward secrecy across archives, no rotation
feature), weak passwords (with concrete brute-force numbers),
metadata leakage from archive structure (block sizes, count,
timestamps visible), network attacks (not a network protocol),
multi-party access (no threshold scheme), plausible
deniability (fixed magic bytes), CRIME/BREACH-style
compression-side-channel (mitigation:
--no-compressif attacker-chosen plaintext is mixed with secrets) - Cryptographic assumptions: explicit list of which standard primitives Zupt relies on and what breaks if any of them fall
- Reporting security issues: contact, expected response time, CVE/advisory commitment
Expanded tests/test_packaging_syntax.sh
Now 22 assertions (up from 18). New checks:
- CI workflow YAML: parses cleanly with PyYAML; all expected
jobs present (
build-and-test,strict-warnings,sanitizers,dist-reproducibility,packaging-syntax,release) - THREAT_MODEL.md: present, substantive (>3000 bytes — actual size 12 KB), required sections present
This keeps the documentation honest — if someone strips a section
from THREAT_MODEL.md to make a quick edit, the test fails fast.
What didn't change
- No source-code changes in
src/orinclude/except the version bump - Archive format still v1.6
- All 12 findings (F-01..F-12) remain closed; no new findings opened
make distreproducibility unchanged
Verification
makeclean on plain GCC + Clang.makestrict GCC + strict Clang — clean.make test— all 12 suites green (packaging syntax: 22/22).make audit-licenses— clean.make distbyte-identical across two runs.- The new CI YAML parses successfully with PyYAML.
- The
releasejob's tag-vs-version check verified locally by reading the workflow logic.
Honest scope note
This sprint deliberately did not attempt ML-DSA-87 signatures
(multi-sprint vendoring of PQClean) or Jasmin re-wiring (no
jasminc in this CI environment; userMemories notes hands-on
verification is needed). These remain on the roadmap.
Files touched
include/zupt.h (version 2.4.5 → 2.4.6)
.github/workflows/ci.yml (rewritten: 4 → 8 jobs;
tag-triggered release added)
THREAT_MODEL.md (new, 12 KB)
tests/test_packaging_syntax.sh (18 → 22 assertions;
CI YAML + THREAT_MODEL checks)
packaging/aur/PKGBUILD (pkgver 2.4.6)
packaging/debian/changelog (top entry 2.4.6-1)
packaging/rpm/zupt.spec (Version: 2.4.6)
packaging/homebrew/zupt.rb (version 2.4.6)
packaging/nix/flake.nix (version 2.4.6)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.6 row)
AUDIT.md (history entry)
DISTRIBUTION.md (CI section added)
[2.4.5] — 2026-05-20 — RPM + Nix + DISTRIBUTION.md (packaging completion)
Continues the v2.4.4 packaging arc with two more upstream recipes and a comprehensive packaging guide. No source-code changes outside the new packaging-syntax regression test; archive format unchanged.
New packaging recipes
packaging/rpm/zupt.spec — Fedora / RHEL / CentOS RPM
License: AGPL-3.0-or-later AND GPL-3.0-or-later(Zupt + VaptVupt)Name: zupt, version pinned toinclude/zupt.hBuildRequires: gcc, make, glibc-devel, python3 >= 3.8%builduses Fedora's%{optflags}plus the project's preferred-Wall -Wextra -Wpedantic -std=c11%checkrunsmake test(all 12 upstream regression suites)%installputs libzuptsdk under%{_libdir}/%{name}/with both symlinks%fileslists binary, manpage, license, and docs- Written for Fedora 38+ / EPEL 9+; notes for older RHEL inline
packaging/nix/flake.nix — Nix flake (NixOS + nix-flake users)
nixpkgspinned tonixos-24.11channel viaflake-utils- Exposes
packages.<system>.zuptandpackages.<system>.default - Builds for
x86_64-linuxandaarch64-linux doCheck = trueruns the fullmake testsuite during buildinstallPhasecopies libzuptsdk into$out/lib/zupt/so the binary's relative rpath resolves under/nix/storeapps.defaultmakesnix runwork directlydevShells.defaultincludes gcc, make, python3, valgrind, gdb
New documentation
DISTRIBUTION.md — 8 KB guide covering:
- How to produce a reproducible source tarball (
make dist) - Reproducibility properties (sorted files, fixed mtime, gzip
-9n) - Submission flows for all 5 distros (AUR, Debian, Fedora, Homebrew, Nix)
- Concrete command examples for each
- A submission checklist
- Security-posture notes for downstream packagers (every recipe runs
make testso silent regressions can't slip through)
New regression test
tests/test_packaging_syntax.sh — 18 assertions, wired into
make test:
- AUR: bash syntax clean, version matches
include/zupt.h, required fields present (pkgname,pkgver,pkgrel,pkgdesc,arch,url,license,depends) - Debian: all 5 files present (
control,rules,changelog,copyright,source/format);rulesis executable;Source:field correct; changelog top-entry version matches; format is3.0 (quilt);dpkg-parsechangelogaccepts it - RPM: required header tags (
Name,Version,Release,Summary,License,URL,Source0);Versionmatchesinclude/zupt.h; all required sections (%prep,%build,%install,%files,%changelog) - Homebrew: version matches; class + DSL keywords +
installmethodtestblock all present
- Nix: outputs structure present;
pname = "zupt"derivation defined; version matches - DISTRIBUTION.md: file present; covers all 5 distros
Tools used opportunistically when available: ruby -c (Homebrew
syntax), dpkg-parsechangelog (Debian), rpmlint (RPM),
nix flake metadata (Nix). Test skips with - skipped lines when
a tool isn't installed (e.g. ruby is rare in build environments;
the dpkg-dev path was exercised in this sprint and passed).
What didn't change
- No source-code changes in
src/orinclude/except the version bump - Archive format still v1.6
- All 12 findings (F-01..F-12) remain closed; no new findings opened
make distreproducibility unchanged
Cross-recipe version consistency
The new packaging-syntax test enforces that every recipe pins the
same version as include/zupt.h. Bumping the C string at one site
plus running the recipe sync (5 sed lines) keeps all 5 recipes in
lockstep. The test fails fast if any recipe drifts.
Verification
makeclean on plain GCC + Clang.makestrict GCC (full §6 set) — clean.makestrict Clang — clean.make test— all 12 suites green (new: packaging syntax, 18/18).make audit-licenses— clean.make disttwo consecutive runs — byte-identical sha256.
Files touched
include/zupt.h (version 2.4.4 → 2.4.5)
packaging/aur/PKGBUILD (pkgver 2.4.5; source URL updated)
packaging/debian/changelog (top entry 2.4.5-1)
packaging/rpm/zupt.spec (new)
packaging/homebrew/zupt.rb (version 2.4.5; URL updated)
packaging/nix/flake.nix (new)
DISTRIBUTION.md (new)
tests/test_packaging_syntax.sh (new, 18 assertions)
Makefile (test target adds packaging syntax)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.5 row)
AUDIT.md (history entry)
[2.4.4] — 2026-05-20 — distribution packaging + reproducible source tarball
Non-security release. First sprint to ship without closing a finding — all 12 findings F-01..F-12 remain closed, no new findings opened. Focuses on getting Zupt distributable: reproducible source tarballs, upstream packaging recipes for AUR, Debian, and Homebrew.
make dist — reproducible source tarball
New Makefile target producing /tmp/zupt-VERSION.tar.gz that is
byte-identical given the same input source tree. Properties:
- Files sorted by name (deterministic order regardless of filesystem layout)
- mtime fixed to
SOURCE_DATE_EPOCH(default1747699200, override via env) - uid/gid pinned to root (0/0) via
--owner=0 --group=0 --numeric-owner - gzip wrapped with
-9n(no embedded timestamp or filename) - Source-only — no
.o, no built binaries, no.git/tree - Includes the vendored
libzuptsdk.so.2.0.0real file plus its two symlinks (libzuptsdk.so,libzuptsdk.so.2) — caught a tar-type fbug that excluded symlinks during initial implementation - Reproducibility verified by
tests/test_dist_reproducible.sh(12 assertions, wired intomake test)
Used by downstream packagers to pin a stable sha256 in their
recipes. Two consecutive make dist runs on the same tree produced
identical sha256 (verified in the regression test on every CI run).
Upstream packaging
Three new packaging trees at packaging/:
packaging/aur/PKGBUILD — Arch Linux user repository
- pkgname=
zupt, pkgver=2.4.4, arch=(x86_64,aarch64) depends=('glibc'),makedepends=('gcc')build()uses the project's strict-warning flagscheck()runsmake test(10 suites + dist regression)package()installs the binary, manpage, docs, and the vendoredlibzuptsdk.so*triple at/usr/lib/zupt/- License:
AGPL-3.0-or-later
packaging/debian/ — Debian source package layout
control— multi-paragraph package description listing PQ hybrid, Argon2id, byte-level tamper detection, Jasmin CT, NIST/RFC vectorsrules— debhelper-compat=13,SOURCE_DATE_EPOCH=1747699200,hardening=+all, project CFLAGS, override_dh_auto_install moves libzuptsdk to/usr/lib/zupt/. Marked executable.changelog— UNRELEASED 2.4.4-1 entry for downstream maintainercopyright— DEP-5 format: AGPL-3.0-or-later main + GPL-3.0-or-later for VaptVupt/libzuptsdksource/format—3.0 (quilt)
packaging/homebrew/zupt.rb — macOS Homebrew formula
- Class
Zupt,desc,homepage,url,version 2.4.4 sha256placeholder for the release tarball shadepends_on "python@3.12" => :testfor the tamper test harnessinstallrunsmake, thenmake install, then drops libzuptsdk intolib/zupt/(handles both.dyliband Linux.so.2.0.0fallback)testblock: compresses a payload, extracts it back, byte-compares
What didn't change
- No source-code changes in
src/,include/, ortests/except the newtests/test_dist_reproducible.sh. - Archive format still v1.6, archives byte-identical to v2.4.3.
- The 12 existing findings remain closed; no new findings opened.
- §3.5 byte sweep was skipped per protocol (kickoff stated
format-touching? no).
Verification
makeclean on plain GCC + Clang.makestrict GCC (full §6 set) — clean.makestrict Clang — clean.make test— all 11 suites green (1 new: dist reproducibility, 12/12 assertions).make audit-licenses— clean.make disttwice on same tree → identical sha256 (verified in regression test on everymake testrun).bash -n packaging/aur/PKGBUILD— syntax clean.dpkg-parsechangelog -l packaging/debian/changelog— parses correctly:Source: zupt,Version: 2.4.4-1,Distribution: UNRELEASED.- Fresh
tar xzf zupt-2.4.4.tar.gz && cd zupt-2.4.4 && make && make test— all 11 suites green from a clean unpack.
Findings status
No findings closed or opened. Cumulative ledger:
| ID | Sprint | Title | Status |
|---|---|---|---|
| F-01..F-05 | 2.2.4 | Audit-batch cleanup | fixed |
| F-06 | 2.2.5 | HMAC verifier silently accepts ~6% of MAC tampers (high) | fixed |
| F-07 | 2.2.5 | block_type at index_offset unauthenticated | fixed |
| F-08 | 2.3.0 | Header/footer metadata not MAC'd | fixed |
| F-09 | 2.3.1 | Per-block frame preface unauthenticated | fixed |
| — | 2.4.0 | Methodology: §3.5 byte-sweep mandate | shipped |
| F-10 | 2.4.1 | KDF default: PBKDF2 → Argon2id | fixed |
| F-11 | 2.4.2 | Error-message verbal probe-oracle | fixed |
| F-12 | 2.4.3 | Archive comments | fixed |
| — | 2.4.4 | Distribution packaging + reproducible dist | shipped |
Files touched
include/zupt.h (version 2.4.3 → 2.4.4)
Makefile (.PHONY adds dist; new dist target;
test target adds test_dist_reproducible.sh)
tests/test_dist_reproducible.sh (new, 12 assertions)
packaging/aur/PKGBUILD (new)
packaging/debian/control (new)
packaging/debian/rules (new, executable)
packaging/debian/changelog (new)
packaging/debian/copyright (new, DEP-5)
packaging/debian/source/format (new)
packaging/homebrew/zupt.rb (new)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.4 row)
AUDIT.md (history entry)
[2.4.3] — 2026-05-20 — F-12: encrypted archive comments
Implements the previously-reserved comment_offset field in
zupt_archive_header_t. Adds free-form UTF-8 archive comments that
are MAC-protected end-to-end and decryption-gated on encrypted
archives.
F-12 — Archive comments (new feature)
Use case. Users want to embed metadata in an archive that
travels with it — purpose, source path, customer ID, GDPR-erasure
notes, restore instructions. Previously the only place to put this
was the filename. The comment_offset field has been reserved in
the header since v1.0 but was unused.
On disk. A new block type ZUPT_BLOCK_COMMENT = 0x05 is written
between the last data block and the central index. The block layout
is identical to a data block:
[2B magic 0xBB 0x01][1B block_type=0x05][2B codec_id=STORE][2B block_flags]
[varint uncompressed_size][varint compressed_size][8B plaintext-XXH64]
[payload: UTF-8 comment text, encrypted iff block_flags has ENCRYPTED]
hdr.comment_offset is set to the file offset of this block, or 0
when no comment is present.
Encryption. For encrypted archives the comment block goes through the same AEAD pipeline as data blocks:
- AES-256-CTR + HMAC-SHA256
- F-09 preface AAD (binds block_type/codec_id/flags/sizes/XXH64 into the MAC)
aad_seq = 0xFFFFFFFFFFFFFFFE(one less than the index's0xFFFFFFFFFFFFFFFFsentinel; cannot collide with file-block aad_seqs which encode(fi+1, block_seq)in the upper/lower 32-bit halves and are bounded above by0xFFFFFFFF00000000)
Header coverage. comment_offset lives in hdr[44..51], which
is part of the AIT MAC input from v1.5 onwards. Tampering the
offset → AIT auth-fail at open time. Tampering the block payload →
per-block HMAC fail at decompress time.
Backward compatibility. No format-minor bump. v2.4.2 readers
seek by file index entries (not sequentially), so a comment block
between data and index is skipped. They ignore comment_offset
entirely. v2.4.2 readers extract v2.4.3 archives byte-exact —
they just don't display the comment.
CLI surface
-c, --comment <TEXT> Embed a free-form archive comment.
--comment-file <FILE> Read comment from FILE (max 4096 bytes; trailing
whitespace stripped so editor newline doesn't
affect roundtrip equality).
Both flags work on c (compress) and disk backup. Empty string
is treated as no-comment (header comment_offset stays 0). Max
comment length is ZUPT_MAX_COMMENT_LEN = 4096 bytes.
zupt info <archive> reports the presence of a comment but
doesn't decrypt it (no keyring at info time). The text appears at
the end of zupt x output after the file-extraction summary.
What didn't change
- Archive format still v1.6.
- The per-block crypto pipeline is unchanged — comment blocks use
the exact same
zupt_encrypt_buffer_aad/decompress_blockpaths as data blocks, so F-06 / F-09 protections apply transparently. - The F-09 strict structural validation of the enc-header block is unchanged.
- v2.4.2 archives extract identically.
Verification
- §3.5 exhaustive byte sweep on a 1878-byte PQ-SDK archive with a comment block: 0/1878 silent acceptances. The comment block bytes are fully MAC-covered.
make test— all 11 suites green including newtest_f12_comment.sh(11 assertions).make test-vectors— 14/14.make test-f06— 2000/2000.make test-asan--pq-sdkbyte-exact roundtrip oninclude/with a comment — clean.- Strict GCC + Clang warning matrix — clean (had to split a help-
text string literal that crossed ISO C99's 4095-char limit; now
emits via two
fprintfcalls). - 50× audit-suite stress — 50/50 green.
F-12 regression test coverage
tests/test_f12_comment.sh — 11 assertions:
- Plaintext archive: comment roundtrips
- Argon2id-password archive: comment roundtrips
- PBKDF2-password archive: comment roundtrips
- PQ-SDK archive: comment roundtrips
infodoes NOT leak the encrypted comment plaintextinforeports comment presence- Tampering the comment block payload is rejected (per-block HMAC)
- Tampering
hdr.comment_offsetis rejected (AIT) - Archive without a comment shows no
Comment:line ininfo --comment-filereads from disk- Empty
-c ""is treated as no-comment
Files touched
include/zupt.h (version 2.4.2 → 2.4.3,
ZUPT_BLOCK_COMMENT, ZUPT_MAX_COMMENT_LEN,
comment field in zupt_options_t,
has_comment field in zupt_options_t)
src/zupt_format.c (write_comment_block helper,
wired into both compress paths,
open_archive reads comment after AIT,
zupt_info shows presence,
zupt_extract prints comment)
src/zupt_main.c (-c / --comment-file parsers at 2 sites,
help text, help-string split for ISO C99)
tests/test_f12_comment.sh (new, 11 assertions)
Makefile (test target)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.3 row)
AUDIT.md (history entry)
SECURITY.md (comment row added)
docs/FINDINGS-2.x.md (F-12 closed)
[2.4.2] — 2026-05-20 — F-11: error-message hygiene (no more "tampered" on wrong password)
Closes F-11, the deferred message-UX issue from sprint 2.4.1.
Symptom (pre-2.4.2)
Extracting an encrypted archive with the wrong password produced this on stderr:
Error: archive-integrity-trailer (top-MAC) verification failed.
The archive header or footer has been tampered with.
Error: Authentication failed (wrong password?)
The "header or footer has been tampered with" framing made users
think their archive was corrupted when in fact they had just
mistyped a password. The wording was inherited from the actual
tamper case — both cases share the same code path (AIT verified
with kr->mac_key, which is derived from the password).
Fix
Two-pronged:
Wording change. Encrypted-archive AIT failure and SDK envelope decryption failure both now print the same generic line by default:
Error: Authentication failed (wrong key, wrong password, or tampered archive).
The detailed "archive-integrity-trailer (top-MAC) verification
failed" wording moves behind --verbose. Plaintext archives
(where no key is involved and the failure is unambiguously
corruption) keep the original detailed wording.
Probe-oracle property preserved. The default message is identical in three distinct failure cases:
| Case | Default message |
|---|---|
| Wrong password (Argon2id) | "Authentication failed (wrong key, wrong password, or tampered archive)" |
| Wrong password (PBKDF2) | "Authentication failed (wrong key, wrong password, or tampered archive)" |
| Wrong PQ-SDK key | "Authentication failed (wrong key, wrong password, or tampered archive)" |
| Actual header tamper (encrypted) | "Authentication failed (wrong key, wrong password, or tampered archive)" |
This collapses what was previously a verbal probe-oracle (different
wording per failure cause) into a single uniform error. Timing is
unchanged — ait_verify always runs the HMAC, branchless return,
unchanged from F-08 / v2.3.0.
Plaintext-mode tamper detection (no key involvement) keeps the detailed XXH64-failure message because there's no oracle concern:
Error: archive-integrity-trailer (XXH64) verification failed.
The archive header or footer has been corrupted or tampered with.
What didn't change
- No format change (still v1.6).
- No code paths for the actual cryptographic verification — only the error-message strings and the verbose-gated detail line.
- No CLI surface change beyond
--verbosenow affecting these messages (the flag already existed). - Wrong password still fails to extract; wrong key still fails to extract; tampered archives still fail to extract. Only the on-stderr explanation differs.
Verification
makeclean on plain GCC + Clang.makestrict GCC (full §6 set) — clean.makestrict Clang — clean.make test— all 10 suites green (test_f11_authfail_message.sh adds 12 assertions). The F-08 test was updated to accept either the generic or detailed-with---verbosewording.make test-vectors— 14/14.make test-f06— 2000/2000.make test-asan--pq-sdkbyte-exact roundtrip oninclude/— clean.make audit-licenses— clean.- 50× audit-suite stress — 50/50 green.
§3.5 byte sweep was skipped per the v2.4.0 sprint protocol because
this release is not format-touching (kickoff template noted
format-touching? no). The change is to error-message strings and
to a verbose-gating branch — no on-disk bytes change, no MAC
inputs change.
Findings
| ID | Title | Status |
|---|---|---|
| F-11 | "Tampered" error message on wrong-password extract misleads users | fixed |
Files touched
include/zupt.h (version 2.4.1 → 2.4.2)
src/zupt_format.c (open_archive AIT-fail branch;
PQ-SDK and Argon2id init-fail
branches in read_enc_header)
src/zupt_disk.c (zupt_disk_restore AIT-fail branch)
tests/test_f08_topmac.sh (assertion updated for new
default+verbose message wording)
tests/test_f11_authfail_message.sh (new, 12 assertions)
Makefile (test target)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.2 row)
AUDIT.md (history entry)
docs/FINDINGS-2.x.md (F-11 closed)
[2.4.1] — 2026-05-20 — F-10: Argon2id as default for password-mode
First sprint after the v2.4.0 methodology release. Flips the default
KDF for password-based encryption from PBKDF2-SHA256 to Argon2id.
PBKDF2 remains available via --kdf pbkdf2 for compatibility with
v2.4.0-and-older readers.
F-10 — Password-mode KDF default upgraded to Argon2id
Severity: N/A (security improvement, not a bug fix)
Component: src/zupt_format.c (write_enc_header password branch),
src/zupt_main.c (CLI parser), include/zupt.h
(zupt_options_t.kdf_legacy_pbkdf2)
Why now. PBKDF2-SHA256 with 600 000 iterations is fine, but
Argon2id is the OWASP recommendation and the modern best practice
for password KDFs. The memory-hardness of Argon2id makes brute-force
attacks on GPUs and ASICs orders of magnitude more expensive than
against PBKDF2. The infrastructure was already present:
zupt_sdk_password_encrypt_init (Argon2id + AES-256-CTR + HMAC-SHA256)
and zupt_sdk_password_decrypt_init shipped in earlier work and
were dispatched on the enc_type = 0x04 (ZUPT_ENC_PW_ARGON2) byte
of the encryption header. Read-path dispatch already handled both
enc_types. The only change needed was flipping the write-path
default from ZUPT_ENC_PBKDF2 (0x01) to ZUPT_ENC_PW_ARGON2 (0x04).
What changed.
-
write_enc_headerpassword branch: ifopts->kdf_legacy_pbkdf2 == 0(default), callzupt_sdk_password_encrypt_initand emit the 33-byte Argon2id enc-header ([type=0x04][16B salt][16B nonce]). Otherwise emit the 53-byte PBKDF2 enc-header ([type=0x01][32B salt][16B nonce][4B iter=600000]) as before. -
New CLI flag
--kdf <argon2id|pbkdf2>onc(compress) anddisk backupcommands. Default (no flag) = Argon2id.--kdf argon2idis the explicit form.--kdf pbkdf2selects legacy mode.--kdf garbagereturns an error. -
The per-block ciphertext pipeline is identical in both modes — both produce the same
kr->enc_key/kr->mac_key/kr->base_nonceand feed AES-256-CTR + HMAC-SHA256 + F-09 preface-AAD. Only the enc-header bytes and KDF differ. F-09's full-archive byte-level tamper detection carries over unchanged (verified: header + footer sweep on a v2.4.1 Argon2id-default archive shows 0 undetected positions; body sampled every 4 bytes also clean).
What didn't change.
- Archive format version still v1.6. The format does not need a
bump — both enc-header layouts have been valid since the
enc_typedispatch was introduced; only the default flips. - Read path: unchanged. Existing dispatch on
enc_typebyte at offset 0 of the enc-header block already handles both 0x01 and 0x04. v2.4.0 readers extract v2.4.1 Argon2id archives without modification (verified — v2.4.0 already linkedzupt_sdk_password_decrypt_init). - PQ-SDK mode (
--pq-sdk) is unaffected; it uses its ownZUPT_ENC_PQ_SDK_V2 (0x03)enc-header.
Verification
makeclean on plain GCC + Clang.makestrict GCC (full §6 set) — clean.makestrict Clang — clean.make test— all 9 suites green (now includestest_f10_kdf_default.sh, 10/10 assertions).make test-vectors— 14/14.make test-f06— 2000/2000, 0 silent accepts (Argon2id path inherits the F-06 fix via the sharedzupt_decrypt_buffer_aad).make test-asanArgon2id-default password roundtrip oninclude/(12 files) — byte-exact, clean.make audit-licenses— clean.- §3.5 byte sweep on Argon2id-password archive (369 bytes, exhaustive header + footer + sampled body): 0 undetected of all positions tested.
- Cross-version compatibility:
- v2.4.0 (PBKDF2) archive → v2.4.1: byte-exact extract ✓
- v2.4.1 (Argon2id default) archive → v2.4.0: byte-exact extract ✓ (v2.4.0 already supports Argon2id reading via existing dispatch)
- v2.4.1
--kdf pbkdf2archive → v2.4.0: byte-exact extract ✓
Known UX caveat (not blocking)
When the wrong password is supplied to extract an Argon2id-default archive, the error message reads:
Error: archive-integrity-trailer (top-MAC) verification failed.
The archive header or footer has been tampered with.
This is technically correct (the AIT verification uses
kr->mac_key, which depends on the password; wrong password →
wrong mac_key → AIT mismatch). But the "tampered" framing misleads
users into thinking their archive is corrupted when they just
mistyped. Same issue exists on PBKDF2 archives and on PQ-SDK
archives since 2.3.0 / F-08. Tracked as F-11 in
docs/FINDINGS-2.x.md, deferred to a future sprint that can rework
the error path to distinguish auth-fail from integrity-fail without
giving timing attackers a clean side channel.
Files touched
include/zupt.h (version 2.4.0 → 2.4.1, kdf_legacy_pbkdf2 field)
src/zupt_format.c (write_enc_header password branch)
src/zupt_main.c (--kdf parser at 2 compress sites, help text)
tests/test_f10_kdf_default.sh (new, 10 assertions)
Makefile (test target)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.1 row)
AUDIT.md (history entry)
SECURITY.md (crypto-defaults table updated)
docs/FINDINGS-2.x.md (F-10 closed, F-11 opened)
[2.4.0] — 2026-05-20 — Methodology release: §3.5 byte-sweep mandate
Documentation-and-process release. No code changes that affect archive format, MAC inputs, or binary behaviour. Encrypts and extracts identically to v2.3.1 — same format v1.6, same archive bytes, same per-block AAD policy.
Why this is a separate release: the five-sprint arc from F-02 →
F-09 surfaced a methodology gap that's worth shipping explicitly
before adding the next feature. Every one of F-02, F-06, F-07,
F-08, F-09 was found by the exhaustive byte sweep — none by the
design review, the unit tests, or the per-byte tamper specs in
tests/test_audit.sh. The sweep itself is ~3 minutes per archive
size to run. It needs to be a sprint-protocol step, not a one-off.
What changed
PROMPT.md — Prompt v2.
- NEW §3.5: The exhaustive byte-sweep mandate. Every format-touching change runs the full byte sweep before claiming done; encrypted archives must reach 0 undetected; plaintext residual gaps must be documented per finding (not gated per release). What counts as "format-touching" is enumerated. Includes the full sweep recipe and the v2.2.4 → v2.3.1 history table showing what the sweep caught at each sprint.
- §6 sprint protocol grows a step. New step 5 ("Byte sweep") between flake stress and plan. Numbering ripple fixed (step 6 was duplicated in v1, step 9 was duplicated). Step 8 ("Re-verify") now mentions re-running the sweep on the final built binary.
- §10 kickoff template adds
format-touching? yes/no. Gates the §3.5 step. - §11 outage table grows four rows — F-06, F-07, F-08, F-09 with the regression-test names that catch each one. The table is the canonical "things that have shipped and must never recur" reference; keeping it current is part of every sprint.
- Footer stamp: Prompt v2, 2026-05-20.
Makefile. The help-target banner version is now derived
from include/zupt.h via a grep | awk substitution:
help:
@echo "Zupt v$(shell grep '^#define ZUPT_VERSION_STRING' \
include/zupt.h | awk -F'"' '{print $$2}') build targets:"
This closes a recurring bug noted in PROMPT.md §6 step 9 —
prior sprints (2.3.0, 2.3.1) left the banner stale even after the
sprint protocol said to bump it. Making it auto-derived removes
the drift opportunity entirely.
What didn't change
- No source files in
src/modified. - No header layout changes in
include/zupt.hbeyond the version string. - No format constants changed.
- No new flag bits, no new struct fields.
- v2.4.0 archives are byte-identical to v2.3.1 archives.
Verification
makeclean on plain GCC + Clang.makestrict GCC (full §6 set) — clean.makestrict Clang — clean.make test— all 8 suites green, F-09 sweep 1827/1827 detected.make test-vectors— 14/14.make test-f06— 2000/2000.make audit-licenses— clean../zupt versionreports 2.4.0;make helpbanner auto-derives the same string.- A fresh archive built by v2.4.0 extracts byte-exact under v2.3.1 (since no on-disk bytes changed).
Files touched
PROMPT.md (§3.5 NEW, §6 step renumber, §10 kickoff, §11 rows, v2 stamp)
Makefile (help banner auto-derives version from header)
include/zupt.h (version 2.3.1 → 2.4.0)
CHANGELOG.md (this entry)
ROADMAP.md (2.4.0 row)
AUDIT.md (header date)
[2.3.1] — 2026-05-20 — F-09 closed: full archive byte coverage (format v1.6)
Second format bump in two sprints: v1.5 → v1.6. Closes F-09 (per-block frame preface tamper window) and reaches 100% byte-level tamper detection on encrypted archives — the exhaustive byte sweep harness reports zero silent acceptances out of 1827 positions tested.
Findings closed
F-09 — Per-block frame preface bytes tamper-tolerant. Post-2.3.0,
the exhaustive byte sweep of a v1.5 PQ-SDK archive showed 18
silent-accept positions remaining, all in per-block frame preface
fields: codec_id, block_flags, varint padding, plaintext-XXH64
checksum field. The per-block HMAC input was
nonce || ciphertext || aad_seq and didn't cover the preceding
preface bytes that the parser reads off the file.
Fix — two-pronged:
Part 1: Extended-AAD MAC binding (v1.6 archives). New crypto
primitives zupt_encrypt_buffer_aad and zupt_decrypt_buffer_aad
take an additional aad_extra buffer that prepends to the existing
MAC input. The legacy functions are now thin wrappers that pass
aad_extra=NULL, len=0, preserving byte-exact MAC output for v1.4
and v1.5 archives.
Callers in src/zupt_format.c build a 29-byte canonical preface from
the same fields stored on disk:
preface_aad = block_type(1) || codec_id(2 LE) || block_flags(2 LE)
|| uncompressed_size(8 LE) || compressed_size(8 LE)
|| plaintext_checksum(8 LE)
The MAC input becomes preface_aad || nonce || ciphertext || aad_seq.
Crucially, the preface AAD uses fixed-width LE serialization, NOT
the on-disk varint encoding for usz/csz — varints have multiple valid
encodings of the same logical value (e.g. 5 as 0x05 or 0x85 0x00),
and a non-canonical varint would produce a different MAC despite
encoding the same archive. Fixed-width LE is canonical, so encode/
decode roundtrip MACs match.
The decrypt path is strict single-candidate when AAD is in use —
no v1/v2 fallback. There's no downgrade attack because the policy
flag ZUPT_FLAG_AAD_PREFACE (bit 9 of global_flags) is itself
MAC-protected by the v1.5 archive-integrity-trailer (F-08). An
attacker can't flip the flag without auth-fail at AIT verification.
Part 2: Strict structural validation of the encryption-header
block. The enc-header block is plaintext by necessity (it carries
the key-establishment data needed before any key can be derived),
so the AAD-MAC pattern doesn't apply to it. But its frame preface
fields can be tightened structurally — same pattern as F-07 for the
index block in v2.2.5. read_enc_header now requires:
block_type == ZUPT_BLOCK_ENC_HEADER(was implicit)codec_id == ZUPT_CODEC_STORE(envelope is never compressed)block_flags == 0(envelope has its own crypto, no extra flags)compressed_size == uncompressed_size(no length games)plaintext-XXH64 == zupt_xxh64(payload, csz, 0)(actual content check)
Together these close the 14 enc-header preface bytes that Part 1 couldn't reach.
Result
| Sprint | Format | Bytes per archive | Undetected-tamper count |
|---|---|---|---|
| 2.2.4 | v1.4 | 1771 | 86 |
| 2.2.5 | v1.4 | 1771 | 86 (F-06 reduced probability, not position count) |
| 2.3.0 | v1.5 | 1803 | 18 |
| 2.3.1 | v1.6 | 1803-1827 | 0 |
The new tests/test_f09_preface.sh runs the exhaustive sweep on a
fresh PQ-SDK archive every make test invocation. As of 2.3.1:
1827/1827 byte tampers detected.
New artefacts
tests/test_f09_preface.sh— exhaustive byte sweep regression. Builds a PQ-SDK v1.6 archive, flips one byte at a time across all ~1800 positions, asserts every tamper is rejected. Catches any future regression that re-opens the preface-tamper window.- Wired into
make test.
Cross-version compatibility (verified)
- v2.3.1 reads v1.5 (v2.3.0) archives byte-exact —
decompress_blocknoticeskeyring.use_preface_aad == 0and calls the legacy decrypt path that doesn't expect AAD bytes. - v2.3.0 cannot read v1.6 archives — rejects with auth-fail because the MAC includes preface AAD bytes v2.3.0's decrypt doesn't feed in. Clean rejection, not silent corruption. This is the intended behaviour: v2.3.0 readers can't ignore the new flag bit without losing F-09's tamper protection.
- v1.4 archives still extract under v2.3.1 with the F-08 downgrade-warning stderr line, unchanged from v2.3.0.
Threat model surface change
SECURITY.md integrity table gains a new row:
| Against tampering of per-block frame preface bytes | v1.6 archives: full MAC coverage (codec_id, block_flags, sizes, plaintext-XXH64 all bound). v1.5 archives: not covered (legacy). v1.4 archives: not covered (legacy). |
Verification matrix
make— clean on plain GCC + Clang.makewith strict GCC-Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat-security -Wlogical-op -Wjump-misses-init -Wdouble-promotion -O2 -std=c11— clean.makewith strict Clang same set — clean.make audit-licenses— clean.make test— all 8 test suites green (61 existing cases + F-08's 4 + F-09's 1827-position sweep).make test-vectors— 14/14.make test-f06— 2000/2000, 0 silent accepts (F-06 unchanged despite the crypto refactor — the legacyzupt_decrypt_bufferis now a thin wrapper, but the F-06 fix lives in the shared_aadimplementation).make test-asan--pq-sdkbyte-exact roundtrip oninclude/(12 files) — clean.- 50× audit-suite stress — 50/50 green.
- Exhaustive byte sweep on 1803-byte v1.6 PQ-SDK archive — 0 undetected of 1803.
- v2.3.0 archive read by v2.3.1: byte-exact extract.
- v2.3.1 archive read by v2.3.0: clean auth-fail rejection.
Files touched
include/zupt.h (version 2.3.0 → 2.3.1,
format 1.5 → 1.6,
ZUPT_FLAG_AAD_PREFACE,
use_preface_aad keyring field,
zupt_*_buffer_aad prototypes)
src/zupt_crypto.c (new _aad encrypt/decrypt;
legacy fns become thin wrappers)
src/zupt_format.c (preface AAD serializers,
4 encrypt sites wired,
decompress_block wired,
open_archive flag propagation,
read_enc_header strict validation)
tests/test_f09_preface.sh (new, exhaustive byte sweep)
tests/test_f08_topmac.sh (accept v1.5+ not just exactly v1.5)
Makefile (test target + version banner)
CHANGELOG.md (this entry)
ROADMAP.md (2.3.1 row)
AUDIT.md (header date + version 2.3.1)
SECURITY.md (integrity table updated)
docs/FINDINGS-2.x.md (F-09 closed)
[2.3.0] — 2026-05-20 — F-08 closed: top-MAC over header+footer (format v1.5)
Minor release, first format bump in the 2.x line: v1.4 → v1.5. Closes F-08 (cosmetic-metadata coverage) deferred from 2.2.5. Forward-compatible write path (always emits v1.5); backward-compatible read path (v1.4 archives extract with a downgrade warning on encrypted modes).
Findings closed
F-08 — Cosmetic archive metadata not covered by any MAC. Pre-2.3.0,
an exhaustive byte sweep of a 1771-byte --pq-sdk archive showed 86
positions where tampering went undetected after F-06/F-07. All 86 were
header/footer informational fields (timestamps, UUID, reserved bytes,
comment offset, footer informational counters, footer version field).
Fix: a new 32-byte archive-integrity-trailer (AIT) appended after the footer.
- Encrypted modes: AIT =
HMAC-SHA256(mac_key, hdr[0..63] || footer[0..23]) - Plaintext modes: AIT =
XXH64(...)in the first 8 bytes, zeros in the rest. Best-effort (OPAQUE-class per PROMPT.md §5).
The MAC input deliberately excludes footer[24..31] = "ZEND" || u32 version.
Both are structurally validated by the read path (locate_footer_v15
rejects bad magic AND non-1 version) so they don't need MAC coverage.
This avoids a circular dependency where the version-bump byte would
need to be authenticated by a MAC keyed off a v1.5-only derivation.
Layout (v1.5 vs v1.4):
v1.5: [header 64B][...blocks...][index][footer 32B][AIT 32B]
v1.4: [header 64B][...blocks...][index][footer 32B]
Read path (open_archive, zupt_archive_info, zupt_disk_restore):
locate_footer_v15 tries v1.5 first ("ZEND" magic at EOF-64 with
correct version), falls back to v1.4 (magic at EOF-32). On v1.5,
verification of the AIT happens AFTER read_enc_header initialises
the keyring, so encrypted archives reject header+footer tamper as
"archive-integrity-trailer (top-MAC) verification failed". On v1.4
archives the read path emits a stderr warning on encrypted modes:
Warning: legacy v1.4 archive without top-MAC (F-08).
File contents are integrity-protected, but header
and footer metadata (timestamps, UUID, counts) are not.
Write path (zupt_compress_files, zupt_compress_solid,
zupt_disk_backup): always emits v1.5. The new helper
zupt_format_ait_write is called immediately after the footer is
written.
Verification
Exhaustive byte sweep of a 1803-byte v1.5 --pq-sdk archive:
| Layer | Before 2.3.0 | After 2.3.0 |
|---|---|---|
| Total bytes in archive | 1771 (v1.4) | 1803 (v1.5, +32 AIT) |
| Bytes where 1-bit tamper goes undetected | 86 | 18 |
| Header bytes 0-63 covered | partial (magic+version only) | 64/64 — full HMAC coverage |
| Footer bytes 0-23 (idx_offset, total_blocks, archive_checksum) | none | 24/24 — full HMAC coverage |
| Footer bytes 24-31 (magic, version) | structural only | structural (excluded from MAC by design; magic and version rejected by locate_footer_v15) |
| Remaining 18 bytes | n/a | per-block header trivia (codec_id, block_flags, varints, checksum field of each per-block frame) — separate concern, tracked as F-09 deferred to v2.3.1 |
F-09 — Per-block-header trivia bytes still tamper-tolerant [deferred, v2.3.1]
The 18 remaining undetected positions in the exhaustive sweep are all
per-block header trivia: bytes between the block magic (offset +0..+1)
and the start of the encrypted payload (+17 onwards) of each per-block
frame. The per-block HMAC covers nonce || ciphertext || aad_seq and
the (block_type, codec_id, block_flags, varint usz, varint csz, xxh64)
preface bytes are not part of the MAC input. Same class as F-07 (which
closed block_type for the index block specifically) but at the
remaining frame-header fields. Closing this needs either a wider HMAC
input on each block (format-compatible — the on-disk layout doesn't
change, only what bytes feed the MAC) or stricter parser validation of
the trivia bytes against expected codec/flag values. Deferred to
v2.3.1 because the bytes are operationally OPAQUE (the parser rejects
malformed varints, the decoder rejects unknown codec_ids, decompression
catches checksum mismatches) — only specific high-bit flag positions
on already-valid frames slip through.
New artefacts
tests/test_f08_topmac.sh— F-08 regression. Builds a v1.5 archive, tampers at 25 header/footer positions, asserts each is rejected with the top-MAC error message. Direction 2 (v1.4 backward compat) runs iftests/fixtures/zupt-2.2.5is available; otherwise skipped with an instructional NOTE.- Wired into
make test(4 cases, 81 → 85 total assertions before counting the legacy v1.4 direction).
Tools and process
- Manual backward-compat verification: built a v1.4 plaintext+encrypted
archive with the 2.2.5 binary (extracted from
zupt-2.2.5.tar.gz), read it with v2.3.0. Plaintext → v1.4 / no top-MAC, byte-exact extract. Encrypted → v1.4 / no top-MAC, downgrade warning shown on stderr, byte-exact extract. - Exhaustive byte sweep confirmed in
/tmp/sweep31/— 18 remaining positions all in per-block-header trivia.
Verification matrix
make— clean on GCC + Clang.makewith strict GCC-Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat-security -Wlogical-op -Wjump-misses-init -Wdouble-promotion -O2 -std=c11— clean.makewith strict Clang same set — clean.make test— 61/61 + F-08's 4 = 65/65 passing (the new test itself adds 4 cases; the surrounding 61 are unchanged).make test-vectors— 14/14.make test-f06— 2000/2000, 0 silent accepts.make test-asan--pq-sdkroundtrip oninclude/(12 files) — byte-exact, clean.tests/test_audit.sh× 50 — 50/50 green.- Manual: tamper byte 15 (header timestamp) of a v1.5 encrypted archive → "top-MAC verification failed".
- Manual: same tamper position on a v1.4 archive built by 2.2.5 → extract still succeeds (no top-MAC to check), legacy warning shown.
Files touched
include/zupt.h (version 2.2.5 → 2.3.0,
format 1.4 → 1.5, ZUPT_AIT_SIZE)
src/zupt_format.c (locate_footer_v15, ait helpers,
open_archive wiring, info update)
src/zupt_disk.c (disk_backup AIT write,
disk_restore AIT verify)
tests/test_f08_topmac.sh (new, 4 assertions)
Makefile (test target + version banner)
CHANGELOG.md (this entry)
ROADMAP.md (2.3.0 row, F-09 entry)
AUDIT.md (header date 2.2.5 → 2.3.0)
SECURITY.md (integrity table updated)
docs/FINDINGS-2.x.md (F-08 closed, F-09 opened)
[2.2.5] — 2026-05-19 — F-06 (high): HMAC accept-on-disjoint-bits
Patch release. Closes one high-severity integrity-bypass on the production x86_64 path (F-06), one low-severity parser-trivia gap (F-07), and re-classifies F-02b (the "unauthenticated index region" hypothesis from 2.2.4) as resolved — the framing was wrong. No format change.
Findings closed
F-06 — zupt_decrypt_buffer silently accepts ~6.35% of single-bit
HMAC tampers on the Jasmin path. The Encrypt-then-MAC verifier
computes two candidate MACs (v2 AAD-bound, v1 legacy) and accepts
iff at least one matches. The combined-diff expression was:
uint64_t diff_v2 = zupt_mac_verify_ct(expected_mac_v2, stored_mac);
uint64_t diff_v1 = zupt_mac_verify_ct(expected_mac_v1, stored_mac);
...
uint64_t diff = diff_v2 & diff_v1; /* BUG */
The Jasmin routine returns a full 64-bit accumulator (OR of 4 × u64
XORs). When both MACs mismatch — i.e. when tamper has occurred —
diff_v2 & diff_v1 is still zero whenever the two diffs have disjoint
nonzero bits. For a 1-bit tamper, diff_v2 has exactly one bit set;
diff_v1 is OR-of-4-random-u64s with on average 4 zero bits out of
64; AND-is-zero probability ≈ 4/64 ≈ 6.25%. Empirically confirmed:
127/2000 silent acceptances in unit-test trials before the fix,
0/2000 after.
In live archive testing this manifested as ~2% of single-bit
len-50 tampers on --pq-sdk archives going undetected — the
"residual flake" from F-02 of 2.2.4. The 2.2.4 hypothesis (that the
index region was not MAC'd) was wrong: the index IS MAC'd correctly
on the encrypt side, but the verifier accepted ~6% of tampers in the
HMAC bytes themselves.
Fix at src/zupt_crypto.c:438-444 — fold each diff to a single
nonzero-indicator bit before ANDing, constant-time:
uint64_t nz_v2 = (diff_v2 | (uint64_t)(-(int64_t)diff_v2)) >> 63; /* CT-REQUIRED */
uint64_t nz_v1 = (diff_v1 | (uint64_t)(-(int64_t)diff_v1)) >> 63; /* CT-REQUIRED */
uint64_t diff = nz_v2 & nz_v1;
(x | -x) >> 63 is the standard branchless "is nonzero" indicator
(0 → 0, anything else → 1) with no data-dependent branches or
secret-dependent memory access. The C-fallback path adopts the same
shape to prevent future divergence.
Severity calibration: high but not critical. The attacker cannot forge MACs with chosen content — they can only randomly tamper and get lucky with ≈6% probability per 1-bit flip; multi-bit tampers decrease exponentially. Plaintext is not recoverable; keys remain protected. But the bug breaks the integrity guarantee SECURITY.md states ("any modification is detected with overwhelming probability"), so it must ship as a patch.
F-07 — open_archive() did not verify block_type at
index_offset. The block_type byte is not part of the MAC input,
so flipping it (e.g. from 0x02 INDEX to 0x03 ENC_HEADER) did
not cause auth failure; the downstream parser was tolerant.
Severity: low. Fix at src/zupt_format.c adds the structural
check immediately after read_block. This makes the byte
OPAQUE-class per PROMPT.md §5 (tamper detected by parser, not by
MAC).
F-02b — RECLASSIFIED. The 2.2.4 hypothesis that the archive
index region was not MAC'd was incorrect. Exhaustive byte sweep
showed three undetected-tamper positions inside the index region;
one (byte 1620) was F-07, one (byte 1624) is an OPAQUE-class
reserved-flag byte that doesn't carry security-significant data,
and one (byte 1713) was F-06 manifesting in the HMAC tail. The
index region IS HMAC-protected; the bug was in the verifier. F-02b
closed without the planned v1.5 format bump.
F-08 — Cosmetic archive metadata not covered by any MAC,
deferred to v2.3.0. Exhaustive byte sweep of a 1771-byte
--pq-sdk archive shows 86 remaining undetected-tamper positions
after F-06+F-07. All 86 fall into header timestamps, UUIDs,
reserved fields, comment offsets, and footer informational
counters — none affect file contents, key material, or
authentication coverage of payloads. The footer's
archive_checksum field, despite the name, is not a cryptographic
MAC (it stores a length value, kept for historical reasons). Fix
deferred to v2.3.0 alongside the planned top-level archive MAC
over header[0..63] || footer[0..23] using the existing
mac_key. This is a format bump (v1.4 → v1.5) and best done with
other v2.3.0 changes than as a standalone patch.
New artefacts
tests/test_f06_hmac.c— F-06 regression. 2000 trials with rotating 1-bit MAC flip, asserts zero silent acceptances. Wired into Makefile asmake test-f06. Demonstrably catches the bug: revertingsrc/zupt_crypto.cto the buggydiff_v2 & diff_v1produces 127/2000 silent accepts and the target fails.
Verification
make— clean on plain GCC and Clang.makewith strict GCC flags (full set from PROMPT.md §6) — clean.makewith strict Clang flags — clean.make test— 61/61 passing.make test-vectors— 14/14 passing.make test-f06— 2000/2000 trials, 0 silent accepts.tests/test_audit.sh× 50 standalone runs — 50/50 green.- ASAN
--pq-sdkbyte-exact roundtrip oninclude/(12 files) — clean. - 200 live
--pq-sdkarchive tamper trials at bytelen-50— 200/200 rejected (was 198/200 pre-fix on the same workload). - Exhaustive byte sweep of all 121 index-region bytes — 0 silent accepts (was 3 pre-fix).
- Reverted-fix sanity check: removing the F-06 patch reproduces
~127/2000 silent accepts in
make test-f06, confirming the test drives the buggy path.
Files touched
src/zupt_crypto.c (F-06: 3-line fix + 22-line comment)
src/zupt_format.c (F-07: 4-line check in open_archive)
tests/test_f06_hmac.c (new, F-06 regression)
Makefile (new test-f06 target, version banner)
include/zupt.h (version 2.2.4 → 2.2.5)
docs/FINDINGS-2.x.md (F-06, F-07, F-08; F-02b closed)
CHANGELOG.md (this entry)
ROADMAP.md (2.2.5 row, F-08/v2.3.0 entry)
AUDIT.md (header date 2.2.4 → 2.2.5)
SECURITY.md (integrity statement reaffirmed)
[2.2.4] — 2026-05-19 — Five-finding audit pass (F-01..F-05)
Patch release. No format changes, no feature changes, no on-disk
compatibility impact. Five findings closed against the v2.2.3 baseline
under the methodology in the new top-level PROMPT.md (continuous
improvement prompt) and tracked in docs/FINDINGS-2.x.md (durable
numbered ledger that survives between work sessions).
Findings closed
F-01 — zupt help keygen line missing newline. src/zupt_main.c:41
ended the keygen description with "Key generation" instead of
"Key generation\n", so ./zupt help printed
Key generation zupt version on a single wrapped line. Severity: low
(UX, not security). Regression check added to tests/run_quick.sh —
asserts ≥10 lines matching ^ zupt in help output.
F-02 — Flaky make test (≈10% audit-suite failure) and one
authentication-coverage gap. tests/test_audit.sh previously
tampered byte len-50 of a --pq-sdk archive. PQ-SDK archive sizes
vary by 1–2 bytes per run (ciphertext encoding length variance), so
len-50 occasionally landed inside the archive index region —
bytes between footer.index_offset and the trailing 32-byte
zupt_footer_t — which is not covered by the per-block HMAC. In
those runs the tampered archive extracted cleanly and the suite
flaked. Confirmed in standalone repro: 5 failures in 50 trials
when the archive happened to be 1771 bytes (index region: 1618–1738,
len-50 = 1721 lands at the index byte).
This finding splits into two:
-
F-02a (fixed in 2.2.4) —
tests/test_audit.shnow tampers at absolute offsets 200 and 500, which are deterministically inside the first encrypted block'snonce || ciphertextof any non-empty PQ-SDK archive (header ends around offset 80, body extends to ≈1610). Verified 80/80 green across the standalone 50-run repro and the newtests/test_audit_flake.shharness. -
F-02b (deferred to 2.2.5, format v1.5) — the unauthenticated index region is a real coverage gap. An attacker who can write to the archive can flip bits in
(path, offset, length)index tuples without being caught until extract corruption shows up (or, worse, silently if the flip lands in unused padding). This is not exploitable for plaintext recovery (the body blocks remain HMAC- protected), but it does allow undetected metadata tamper. Closing this needs a format bump: design options indocs/FINDINGS-2.x.mdunder F-02b (preferred:index_mac[32]derived viaHMAC-SHA256(mac_key, index_bytes || footer_header_fields)and stored immediately before the footer; plaintext-mode archives fall back toXXH64(index)as best-effort).
F-03 — -Wshadow: r shadows in zupt_secure_random.
src/zupt_crypto.c:48 declares ssize_t r = syscall(SYS_getrandom, …)
inside a __linux__ block; line 54 declares size_t r = fread(…) in
the surrounding fallback path. Cosmetic — both rs coincidentally
hold counts — but blocks adoption of -Wshadow for the project. Fix:
rename the fread result to nread.
F-04 — zupt_mlkem768_selftest: definition without prototype or
caller. src/zupt_mlkem.c:648 defines an NTT-roundtrip plus
CBD-sample property check that was never wired in. Triggered
-Wmissing-prototypes. The function is genuinely useful (it's an
end-to-end internal correctness probe orthogonal to the FIPS 203
KAT roundtrip already in the test suite), so it is now declared in
include/zupt_mlkem.h and invoked as the 14th case in
tests/test_vectors.c. NIST/RFC vector count goes 13 → 14;
AUDIT.md updated accordingly.
F-05 — cppcheck: three uint8_t * pointers can be const. Three
one-past-end sentinels in src/vv_ans.c (lines 1575, 2228, 2265)
are never written through. Re-typed as const uint8_t *. Closes
the constVariablePointer finding from
cppcheck --enable=all. VaptVupt SPDX header (GPL-3.0-or-later)
preserved.
New process artefacts
-
PROMPT.md— top-level "god-tier" continuous-improvement prompt. Designed to be pasted verbatim into a fresh chat alongside the latest source tarball. Encodes the methodology that produced this release: three-line workflow (survey → fix-with-test → ship), explicit authentication-coverage invariant (every archive byte covered by per-block HMAC OR a separate index MAC OR a footer MAC — no third category permitted), §3 flake-stress mandate (every short assertion runs ≥50× before being declared deterministic — would have caught F-02 on day one), strict-warning matrix, and a numbered findings ledger that survives between sessions. -
docs/FINDINGS-2.x.md— durable numbered ledger for the 2.x series. F-01 through F-05 closed here with reproducers, root cause, fix, regression test, and verification per finding. F-02b stays open at the bottom with three implementation options for v2.2.5. -
tests/test_audit_flake.sh— 5-suite × N-run flake-stress harness (default N=20 for routine use;bash tests/test_audit_flake.sh 50for hardened audit). Aborts on the first non-deterministic outcome and dumps the failing run to a tmp log.
Verification
make— clean on plain GCC and Clang (no warnings).makewith strict GCC flags-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat-security -Wlogical-op -Wjump-misses-init -Wdouble-promotion -O2 -std=c11— clean (was 2 warnings on 2.2.3).makewith strict Clang-Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -O2 -std=c11— clean (was 0 warnings on 2.2.3, still 0).cppcheck --enable=all—constVariablePointerfindings resolved for the three cited sites invv_ans.c.make test(61-case suite) — 61/61 passing.make test-vectors— 14/14 passing (was 13/13; new case is ML-KEM-768 internal self-test).tests/test_audit.shstandalone — 80/80 green across two independent stress runs (50 + 30) of the previously-flaky path.tests/test_audit_flake.sh 10— 10/10 green on the three short suites that fit in the timeout window (test_audit,test_path_traversal,test_arg_order).make test-asan— builds clean; manual compress/extract roundtrip on a 12-file source tree clean under ASAN+UBSAN../zupt help | grep -c '^ zupt '— 22 lines (was 21 on 2.2.3 becausekeygenandversionwere collapsed).
Files touched
PROMPT.md (new, top-level)
docs/FINDINGS-2.x.md (new)
tests/test_audit_flake.sh (new)
src/zupt_main.c (F-01)
src/zupt_crypto.c (F-03)
src/zupt_mlkem.c (F-04: no source change; header gains decl)
include/zupt_mlkem.h (F-04)
src/vv_ans.c (F-05)
tests/test_audit.sh (F-02a)
tests/test_vectors.c (F-04)
tests/run_quick.sh (F-01 regression line)
include/zupt.h (version 2.2.3 → 2.2.4)
Makefile (help banner version)
AUDIT.md (header, vector count 13 → 14)
ROADMAP.md (2.2.4 row, F-02b entry on planned)
CHANGELOG.md (this entry)
[2.2.3] — 2026-05-01 — VaptVupt 2.48.2 integration + Makefile fix
This release upgrades the embedded VaptVupt codec from the v0.1-era
sources that shipped in 2.2.2 to VaptVupt 2.48.2, the version that
was explicitly cut to be the integration target for Zupt 2.2.3 (see
the upstream ZUPT_INTEGRATION.md).
VaptVupt 2.48.2 codec
The codec gains, vs. what 2.2.2 shipped:
- Aggregate ratio now beats zstd-3 by 1.07% in upstream measurement (was +1.2% behind in v2.47.x). Sprint 120's cost-aware lazy parser plus Sprint 121's gating delivered the breakthrough — encoder-only change, wire-format compatible with v2.47.x decoders.
format_v2flag producing 4–7% better real-binary ratios via the T-tag (min_match=3) literal encoding. Wired throughvvz_compressforBALANCEDandEXTREMEmodes (see "Wrapper defaults" below).compat_v246_5_decoderflag for environments stuck on a pre-v2.47 decoder. Default off — Zupt always controls both encoder and decoder, so we always have v2.47+ on the decode side.- Sprint 117 hardened-build compatibility: the codec now compiles
cleanly under
clang -fsanitize=integer(strict UBSan superset; was 92 false positives, now 0). - Sprint 118 memory hygiene: encoder working buffers (
lit_buf,stripped,src_buf,tmp,ent_buf, plus the context struct) are now scrubbed viavv_secure_zerobeforefree()— defence-in- depth specifically for Zupt's compress→encrypt→write pipeline. - Sprint 109/118 decoder hardening: literal-run extension bounds, OOB code-table bounds, NULL-deref protection on edge-case empty symbol tables.
Cumulative upstream audit posture at v2.48.2: 0 cppcheck issues, 0 scan-build bugs, 0 strict GCC/Clang warnings, ~145,000 cumulative sanitised libFuzzer executions across 4 attack surfaces, 0 crashes, 13 cumulative defects fixed across the audit campaign.
Wrapper defaults (src/vaptvupt_api.c)
The thin vvz_compress shim that Zupt's archive layer calls now
applies the integration best practices documented in VaptVupt's
upstream guide:
opts.checksum = 0— Zupt's outer HMAC-SHA256 (or AES-GCM-SIV in--pq-sdkmode) already authenticates the compressed bytes, so the codec's internal XXH64 footer is redundant work. Saves ~10% encode time and pairs withVV_DECOMPRESS_SKIP_CHECKSUMon decode for a 2–5× decode speedup on AEAD-wrapped (high-entropy) payloads.opts.format_v2 = 1forVV_MODE_BALANCED(level 3–7) andVV_MODE_EXTREME(level 8–9) — 4–7% better binary ratio.opts.format_v2 = 0forVV_MODE_ULTRA_FAST(level 1–2). The combination offormat_v2 = 1+ULTRA_FASTis not in VaptVupt 2.48.2's tested matrix (tests/test_zupt_integration.cvalidatesformat_v2only withBALANCED/EXTREME) and produces output the decoder rejects withVV_ERR_OVERFLOW. Caught during Zupt's own regression run (T17 VaptVupt-all-levels) before release; reported upstream and worked around here defensively. Once VaptVupt validates the combination, this guard can be lifted.opts.compat_v246_5_decoder = 0— allowlit_fmt=4(4-stream Huffman) literal coding. Safe because Zupt always ships its decoder at the same version as the encoder (no older decoders in the wild).
Makefile arch-detection fix
The STALE_OBJS arch-safety guard was comparing the canonical strings
x86-64 (from file(1)) against x86_64 (from $(CC) -dumpmachine)
and treating them as different architectures, causing every make
invocation to wipe and rebuild every .o file even on consistent
hosts. Both sides are now normalised through tr -d '_-' | tr [:upper:] [:lower:] so the comparison succeeds on a same-arch tree and only
fires when the tarball really did include cross-arch objects.
Tests
make test— 9 quick + 11 SDK + 10 audit + 12 dedup-property + 5 path-traversal + 8 arg-order + 6 block-swap = 61 passing.tests/regression.sh— 22/22 passing (was 20/22 before the ULTRA_FAST + format_v2 guard).tests/test_threaded.sh— 14/14 passing.tests/test_pq.sh— 10/10 passing.make test-vv— 11/11 passing.make test-vectors— 13/13 passing.make test-asan— clean across plain / password /--pq-sdkarchives at levels 1, 5, 9.make fuzz-format-run— 1000 mutation-fuzz iterations under ASAN/ UBSAN, 0 crashes.- Disk backup/restore byte-exact sha256 verified.
Two make test runs back-to-back, both clean. Cumulative test count:
112 cases passing across 12 suites.
Documentation cleanup
Four design/audit-prompt documents that were sprint-internal scratch have been removed from the source tree (consolidated into the remaining permanent docs):
| Removed | Where the content lives now |
|---|---|
AUDIT_PROMPT.md |
superseded by FORMAL_AUDIT_PROMPT.md |
ROOT_CAUSE_ANALYSIS.md |
reproducible-bug postmortems are now per-release entries in CHANGELOG.md |
COMPAT.md |
the table moved into README.md § "Architecture & platform support" |
DONATIONS.md |
one-liner moved into README.md § "Supporting Zupt" |
Surviving canonical docs: README.md, CHANGELOG.md (this file),
SECURITY.md, INSTALL.md, LICENSE, THIRD-PARTY-NOTICES.md,
AUDIT.md, FORMAL_AUDIT_PROMPT.md, ROADMAP.md.
[2.2.2-final2] — 2026-05-01 — CLI help, man pages, deb copyright
Continuing the license-hygiene work: previously the SPDX headers in source files were correct, but several user-visible surfaces still showed wrong licensing or stale URLs. Fixed:
CLI runtime output
zupt helppreviously ended withLicense: MIT— corrected toLicense: AGPL-3.0-or-later (Zupt) + GPL-3.0-or-later (VaptVupt codec)with aCommercial license available: sac@securityops.coline andProject: https://git.securityops.co/cristiancmoises/zuptlink.zupt versionnow also prints the License, Project URL, and Commercial contact lines (previously omitted entirely).
Man pages
doc/zupt.1: fixed BUGS URL fromgithub.com→git.securityops.co, addedLICENSEsection explaining AGPL+GPL split, addedPROJECTsection listing all 5 sister projects (zupt, zupt-android, zupt-web, libzuptsdk, vaptvupt) with git.securityops.co URLs.doc/zupt-gui.1: same fixes (BUGS URL + LICENSE + PROJECT sections).
Debian copyright file (/usr/share/doc/zupt/copyright)
The previous copyright file claimed everything was AGPL-3.0+. Updated
to a proper Debian-machine-readable format with two Files: stanzas:
Files: *— AGPL-3.0+Files: src/vv_*.c src/vaptvupt_api.c include/vv_*.h include/vaptvupt*.h— GPL-3.0+ (with rationale comment about kernel upstreaming)
Plus a Comment: field pointing at sac@securityops.co for commercial
licensing inquiries.
README logo
The github user-attachments URL for the logo (now broken since the project moved off GitHub) was replaced with a HTML comment placeholder suggesting rehosting at zupt.securityops.co.
Other doc cleanups
- SECURITY.md:
Do not open a public GitHub issue→Do not open a public issue on the project's git server - libzuptsdk README: same
GitHub issues→project's git serverfix.
Verification
- 61/61 tests pass (no behavior change)
make audit-licenses→ ✓ all SPDX correctzupt versionandzupt helpboth display correct license + URL- Man pages (zupt.1, zupt-gui.1) carry full LICENSE + PROJECT sections
[2.2.2-final] — 2026-04-30 — License hygiene + project move
This update does not change any binary behavior. It corrects licensing
metadata across the source tree and updates all repository URLs from
github.com/cristiancmoises/* to git.securityops.co/cristiancmoises/*.
Repository move
The Zupt project has moved from GitHub to a self-hosted Gitea instance:
| Project | New URL |
|---|---|
| zupt (this repo) | https://git.securityops.co/cristiancmoises/zupt |
| zupt-android | https://git.securityops.co/cristiancmoises/zupt-android |
| zupt-web | https://git.securityops.co/cristiancmoises/zupt-web |
| libzuptsdk | https://git.securityops.co/cristiancmoises/libzuptsdk |
| vaptvupt | https://git.securityops.co/cristiancmoises/vaptvupt |
All 29 occurrences of github.com/cristiancmoises/... across 19 files
have been updated. The old GitHub URLs no longer resolve.
License clarification
The README and LICENSE file previously contained inaccurate license claims. Corrected:
- Zupt CLI, libzuptsdk, GUI, Jasmin source: AGPL-3.0-or-later
- VaptVupt LZ codec (
src/vv_*.c,src/vaptvupt_api.c,include/vv_*.h,include/vaptvupt*.h): GPL-3.0-or-later (deliberately GPL not AGPL, so it can eventually be considered for upstreaming into the Linux/BSD kernels which require GPL-compatible licensing). - Commercial licensing (relief from copyleft): contact
sac@securityops.co.
The README's competitive comparison table erroneously listed Zupt under "License: MIT". Corrected to "AGPL+GPL".
SPDX coverage
Every source file in the tree now carries an explicit SPDX header. Previously 46 files in the CLI tree, 5 Jasmin assembly outputs, the CI yml, and a few packaging files had no SPDX line at all. Added:
AGPL-3.0-or-laterto all Zupt CLI sources (46 files),.sassembly outputs (5 files),.github/workflows/ci.yml, packaging Flatpak manifest,sdk/zuptsdk.mapGPL-3.0-or-laterto all VaptVupt sources (8 files that were missing it:vv_ans.c,vv_decoder.c,vv_encoder.c,vv_huffman.c,vv_simd.c,vv_xxh64.c,vv_ans.h,vv_huffman.h)
The 5 Jasmin .jazz source files previously declared "MIT License" in
their headers as a copy-paste artifact from an earlier draft. They have
been relicensed to AGPL-3.0-or-later (sole-author relicensing — no
external contributor's work was relicensed).
The 5 VaptVupt headers in vendor/zuptsdk/include/ (vaptvupt.h,
vaptvupt_api.h, vv_ans.h, vv_huffman.h, vv_platform.h) were tagged
AGPL but should have been GPL since they are VaptVupt headers.
Corrected.
THIRD-PARTY-NOTICES.md rewritten
The previous document framed parts of Zupt as if they were vendored third-party dependencies. They are not. The new document opens with:
Zupt contains no third-party source code. Every line of source in this repository is the work of Cristian Cezar Moisés.
Runtime system libraries (libargon2, OpenSSL libcrypto) are listed as runtime dependencies provided by the OS package manager, not as bundled dependencies. The Jasmin compiler is correctly described as a build-time tool that is not redistributed.
LICENSE file fixes
- "libzuptsdk is free software" → "Zupt is free software" (this is the zupt repo, not the libzuptsdk repo)
- GitHub URL → git.securityops.co URL
- Contact
zupt@riseup.net→sac@securityops.co - Appended a note explaining that VaptVupt is GPL not AGPL, with rationale for the deliberate licensing split
New make audit-licenses target
Verifies on every CI run that:
- All non-VaptVupt source files carry
SPDX-License-Identifier: AGPL-3.0-or-later - All VaptVupt files (
vv_*,vaptvupt*) carrySPDX-License-Identifier: GPL-3.0-or-later
Excludes vendored libzuptsdk headers (vendor/zuptsdk/include/) and
build artifacts (build/, build_obj/, sdk/build/).
Result: ✓ All source files carry correct SPDX headers.
Build dependency: libzuptsdk-dev
The CLI's --pq-sdk path links against libzuptsdk.so.2. Previously,
the source tarball assumed the library would be available in the
build environment. INSTALL.md now has an explicit "Building from
source" section documenting this requirement and pointing at the
libzuptsdk2 / libzuptsdk-dev packages.
Removed obsolete migration docs
FRESH-REPO-SETUP.md and CHANGELOG.fresh-repo.md (one-time docs from
the github-old → github-new migration sprint) deleted as obsolete.
Verification
- 61/61 tests pass (audit, smoke, multi-file, cross-block, dedup property, path-traversal, argument-order, block-swap regression)
- Encrypted compress + extract roundtrip: byte-exact match (MD5 verified)
make audit-licenses→ ✓ all SPDX correct- Source builds cleanly on Ubuntu 24.04 / GCC 13.3 with
libzuptsdk-dev 2.0.0installed
[2.2.2] god-tier audit — bug #16 (block-swap attack) fix
Independent formal cryptographic audit (per FORMAL_AUDIT_PROMPT.md two-pass methodology) discovered a critical authenticated-encryption flaw in the shipped 2.2.2 binary. Investigation, root-cause, fix, regression test, and final verification documented below.
Bug #16 — CRITICAL: Block-swap attack on encrypted archives
Severity: critical — silent data corruption with valid MAC
Affected: all encrypted archives produced by zupt 2.0.0 through 2.2.2.
Root cause: AES-CTR + HMAC-SHA256 in zupt 2.2.2 covered MAC over
(nonce || ciphertext) only. The decryptor read the nonce from the
package itself and ignored the block_seq parameter that was passed
in ((void)block_seq; in src/zupt_crypto.c:zupt_decrypt_buffer). An
attacker who swapped two valid encrypted blocks (full block including
header + checksum + payload) between positions in an encrypted archive
produced an archive that:
- Decrypts cleanly — every block's stored nonce is its actual encryption nonce, so AES-CTR decryption produces correct plaintext
- MAC-verifies cleanly — MAC was computed over (nonce || ct), and both are stored in the swapped block, so the MAC is still valid
- Extracts files with wrong content — file_A.txt receives file_B's content and vice-versa, with zupt reporting "Extracted N file(s)" and no error
Reproduction (confirmed twice in the audit): with two distinct files
A (49 'A' chars) and B (49 'B' chars) compressed with -p mypassword -b 64 -t 1, swapping the two 114-byte DATA blocks at positions 134
and 248 produced an archive that extracted file_A.txt with B's content
and file_B.txt with A's content, with zupt reporting "Extracted 2 file(s)".
Fix architecture: bind block_seq into the MAC as 8-byte
little-endian Associated Data (AAD). The seq is computed as
((file_index_in_archive + 1) << 32) | per_file_block_seq, combining
the file's identity within the archive with its block position within
that file. Both encrypt and decrypt compute this from the same inputs
(fi and b) without changing the wire format. Special cases:
- Index blocks: use sentinel seq
0xFFFFFFFFFFFFFFFFULL(matches existing decrypt at line 1517) - Solid mode: synthetic fi=0 (AAD =
(1 << 32) | block_seq) since there are no per-file boundaries - Dedup mode: sentinel seq=0 — dedup refs (offset-only) can't derive the source file's AAD, so dedup blocks bypass AAD binding. Block-level XXH64 plaintext checksum still provides integrity.
- Backward compat: decrypt tries v2 (with AAD) first, falls back to v1 (legacy, no AAD) for old archives. Both candidates always computed for constant-time policy.
A new archive header flag ZUPT_FLAG_AAD_SEQ (1u << 8) signals that
encrypted blocks bind the seq.
Files modified:
include/zupt.h— addedZUPT_FLAG_AAD_SEQsrc/zupt_crypto.c—zupt_encrypt_buffer/zupt_decrypt_bufferrewrite (AAD binding, dual-MAC fallback)src/zupt_format.c— set flag on new encrypted archives, compute(fi+1, block_seq)AAD at all 3 encrypt sites (regular ST, MT, solid), compute matching AAD at all 4 decrypt sites (ST extract, MT extract, solid extract, test path)- Empty output files on auth failure now
unlink()'d (was leaving 0-byte files on disk)
Regression test: tests/test_block_swap.sh — 6 properties:
- Normal extract still works (regression guard)
- Block-swap attack rejected (cross-file reorder) — exact reproduction of the audit-discovered attack
- Single-block file roundtrip (boundary)
- 512KB multi-block file roundtrip
- Multi-file archive roundtrip (per-file seq counters)
- Wrong password rejected
The attack reproduction in test 2 produces an archive identical to the manual attack used to discover the bug; it must report 0 files extracted with errors, never the swapped-content output that the bug allowed.
Cumulative test surface (2.2.2 final after bug #16)
make test → 9/9 (run_quick)
→ 11/11 (test_sdk)
→ 10/10 (test_audit)
→ 12/12 (test_dedup_props)
→ 5/5 (test_path_traversal)
→ 8/8 (test_arg_order)
→ 6/6 (test_block_swap) NEW
──────
61/61 ✓
make test-asan-run → 61/61 ✓ under ASAN/UBSAN, zero memory errors
make fuzz-format-run → 1000 iters under ASAN/UBSAN, 0 crashes
Inherited libzuptsdk → 169/169 + 750k fuzz iters
──────
Combined zupt + SDK 279 tests + 751k fuzz iters
Cumulative bug count across all audit sprints
| Sprint | Bugs | Severity range |
|---|---|---|
| v2.2.1 (audit 1) | 6 | low to high |
| v2.2.2 (audit 2) | 4 | low to medium |
| v2.2.2 (formal audit 3) | 4 | low to high (Zip Slip) |
| v2.2.2 (sprint 4) | 1 | critical (silent extract) |
| v2.2.2 (god-tier audit) | 1 | critical (block-swap AEAD) |
| Total | 16 | all fixed and regression-tested |
Known limitation accepted
Dedup mode trades AAD-binding for offset-based ref support. An attacker with access to a dedup-mode encrypted archive could swap blocks; the plaintext XXH64 checksum stored per block prevents wrong-content extraction, but error messages may not clearly say "tampered" — they say "checksum mismatch". This is documented in SECURITY.md and is acceptable because dedup is opt-in and not the default.
[2.2.2] formal audit pass — 2026-04-27 (no version bump)
Post-release formal cryptographic audit by senior cryptographic engineering review. Two security-relevant bugs and two robustness bugs found and fixed. Version stays at 2.2.2; this is hardening of the same release.
Bugs found and fixed
-
🚨 HIGH — Path traversal (Zip Slip) in extract path (
src/zupt_format.c, two extract sites). The archive entry'se->pathfield — attacker-controlled in a malicious archive — was passed directly tofopenafter string concatenation with the output directory. A crafted archive containing entries like../../../etc/cron.d/evil,/etc/passwd, orC:\Windows\System32\drivers\etc\hostscould write arbitrary files anywhere the user has filesystem access. This is the Snyk Zip Slip vulnerability pattern (2018). Newzupt_path_is_safe()validator rejects:- empty paths and paths exceeding
ZUPT_MAX_PATH - absolute paths (Unix
/..., WindowsC:, UNC\\server) - any component equal to
.. - embedded NUL bytes
Wired into both extract sites before
fopen. Confirmed via 5-test regression suite.
- empty paths and paths exceeding
-
MEDIUM — Symlink-following on extract output (
src/zupt_format.c).fopen(out_path, "wb")follows symlinks. If an attacker plants a symlink in the user's output directory before extraction (~/Downloads/innocent.txt → /etc/shadow), extract clobbers the symlink target. Newzupt_safe_fopen_output()usesO_NOFOLLOWon POSIX (Linux/BSD/macOS), returningELOOPif the leaf is a symlink. Windows path unchanged — relies on directory ACLs, documented inSECURITY.md. Verified by P3 of the path-traversal regression test. -
LOW —
size_toverflow on solid-extract size cap (src/zupt_format.c:1593). The 4 GiB cap on solid-stream size exceedssize_ton 32-bit platforms, where the subsequentmalloc((size_t)total_size)would silently truncate. Added explicittotal_size > SIZE_MAXguard. -
LOW —
count * sizeof(entry)overflow inparse_index(src/zupt_format.c). WithZUPT_MAX_FILES = 2,000,000andsizeof(zupt_index_entry_t) ≈ 4140, the multiplication overflowssize_ton 32-bit platforms before reachingcalloc's internal overflow check. Added explicitcount > SIZE_MAX / sizeof(entry)guard. (No exploitable behavior on 64-bit; defense for embedded/Termux/legacy platforms.)
Cryptographic primitive review (no findings)
Reviewed against FIPS 197 (AES), FIPS 202 (Keccak/SHA-3), FIPS 203 (ML-KEM), RFC 5297 (AES-SIV), RFC 5869 (HKDF), RFC 7748 (X25519), RFC 8439 (ChaCha20-Poly1305), RFC 9106 (Argon2), and RFC 9180 (HPKE):
- AES-CTR per-block nonce derivation (
base_nonce ⊕ block_seqLE in low 8 bytes): safe by construction.base_nonceis randomly generated per archive (32 bytes fromzupt_random_bytes);block_seqis monotonic and unique within an archive. No nonce reuse possible under any execution path. - Legacy
--pqhybrid combiner: XOR of ML-KEM and X25519 shared secrets, then SHA3-512 with full transcript binding (ml_ct || eph_pk || domain-tag). Acceptable per Bindel et al. 2019; weaker than HKDF combiner used in SDK path, but both modes are intentionally kept for archive compatibility. Documented as legacy. - PBKDF2 key splitting (legacy password mode): derives 64 bytes, splits to 32 enc_key + 32 mac_key. Safe — PBKDF2-SHA256 output is uniformly random.
- SDK header parsing: bounds-checked correctly (
enc_hdr_len < 5guard,blob_sz > enc_hdr_len - 5guard,blob_sz > 1500cap).
New regression test suite
tests/test_path_traversal.sh — 5 property checks:
- P1:
..traversal blocked (patched archive does not escape parent dir) - P2: absolute path entries rejected (does not write to
/tmp/owned) - P3: symlink at extract target not followed (sentinel file preserved)
- P4: legitimate paths still extract correctly (regression guard)
- P5: deep nested safe paths still work (regression guard)
Cumulative test surface (2.2.2 final)
make test → 9/9 (run_quick)
→ 11/11 (test_sdk)
→ 10/10 (test_audit)
→ 12/12 (test_dedup_props)
→ 5/5 (test_path_traversal) NEW
──────
47/47 ✓
make test-asan-run → 47/47 ✓ under ASAN/UBSAN, zero memory errors
make fuzz-format-run → 1000 iters under ASAN/UBSAN, 0 crashes
Inherited libzuptsdk → 169/169 + 750k fuzz iters
──────
Combined zupt + SDK 265 tests + 751k fuzz iters
Portability re-verification
Static portability scan passes:
- No unaligned pointer casts (
*(uint64_t *)ptrpatterns absent) - No raw
/separators in path construction (usesZUPT_PATH_SEPmacro) - No
htonl/ntohlor struct casts (useszupt_le32_get/zupt_le64_getexclusively) - No POSIX-only headers without
#ifdefguards
Compile-tested with -Wpedantic under GCC. Win32 code paths verified via
-D_WIN32 -E synthetic preprocessing — branches reachable, syntax clean.
#ifdef _WIN32 coverage in: zupt_crypto.c, zupt_disk.c, zupt_format.c,
zupt_main.c, zupt_mlock.c. Posix-side gated with explicit #else.
Documentation updates
SECURITY.md: new "Threat model" and "Path traversal mitigation" sectionsAUDIT.md: 2026-04-27 formal audit entry with cumulative test tableREADME.md: Security section bumped with audit confirmationdoc/zupt.1: SECURITY section mentions path-traversal protectionFORMAL_AUDIT_PROMPT.md: methodology document at repo root for future audits
[2.2.2] — 2026-04-27
Continued audit-driven hardening release. Four additional bugs found and fixed by code review; new fuzz harness for the format parser; new property-based tests for the dedup path; full GitHub Actions CI pipeline.
Bugs found and fixed
-
realloc-pair atomicity bug in
zupt_filelist_add(src/zupt_format.c:166). When the firstreallocsucceeded and the second failed, the first realloc had already invalidated the original pointer; the cleanup branch'sif (new_paths != fl->paths) free(new_paths)is undefined behavior becausereallocmay return the same pointer. Worse, when both reallocs succeeded but allocation truncated the array (impossible here, but the pattern is fragile),fl->pathswas updated only whennew_pathswas not NULL — leaving callers with stale pointers in the failure path. Replaced with atomic two-buffer allocation: bothmallocfirst, copy contents, free old buffers only on full success. -
Length-overflow in in-memory varint decoder (
src/zupt_format.c:138).zupt_decode_varinthad the same 9-byte truncation bug as the file-variant decoder fixed in 2.2.1, plus shift-by-64 undefined behavior whensreached 63 with a continuation bit still set. Decoder now accepts up to 10 bytes (sufficient for full uint64) and explicitly rejects continuation past bit 64. -
Unvalidated
encryption_header_off(src/zupt_format.c:1267). A malicious archive could setencryption_header_offto0xFFFFFFFFFFFFFFFF. Casting toint64_tforfseekogives-1; the seek silently fails and subsequent reads happen at an undefined position. Now validates offset is within file size before seeking. -
Unvalidated
index_offset(src/zupt_format.c:1402). Same class of bug as #9, applied to the archive footer's index pointer. Same fix.
Added — Fuzz infrastructure
tests/fuzz_format.c— mutation-fuzz harness for the zupt format parser. Forks a child process per iteration to isolate crashes; mutates a seed archive with byte-flips, byte-sets, zero-runs, 0xFF-runs, and swaps; feeds mutated archives tozupt listand counts crashes vs. clean rejections.make fuzz-formatbuilds the harness.make fuzz-format-runruns 1000 iterations against the ASAN/UBSAN binary. Result: 0 crashes, 159 archives accepted as well-formed despite mutation, 841 cleanly rejected.
Added — Dedup property-based tests
tests/test_dedup_props.sh— 12 property-based checks for the deduplication path:- Roundtrip preserves bytes for 10 base files + 5 duplicates
- Dedup achieves >50% size reduction on 20-copy duplicate-heavy workloads (measured: 95% reduction, 17,346 B vs. 328,550 B)
- 100%-duplicate archives extract correctly with all copies recovered byte-exact
- Dedup + SDK PQ encryption coexist correctly
Added — GitHub Actions CI pipeline
.github/workflows/ci.yml— 4-job pipeline:build-and-test: full test suite (run_quick + sdk + audit + dedup)asan-build: build with-fsanitize=address,undefinedand run all suites under sanitizersfuzz-format: 1000-iteration fuzz under ASAN/UBSANpackage-deb: build + verify .deb installation
Added — THIRD-PARTY-NOTICES.md
Documents all third-party software included or linked: vendored libzuptsdk (AGPL, same author), Jasmin-compiled assembly, libargon2 (Apache 2.0 / CC0), OpenSSL libcrypto (Apache 2.0), VaptVupt (AGPL, in-tree), and standards followed (FIPS 197/202/203, RFCs 5297/5869/7748/8032/8439/9106/9180). Required for redistribution compliance.
Test results
make test → 9/9 (run_quick)
→ 11/11 (test_sdk)
→ 10/10 (test_audit)
→ 12/12 (test_dedup_props) NEW
──────
42/42 ✓
make test-asan-run → 42/42 ✓ under ASAN/UBSAN, zero memory errors
make fuzz-format-run → 1000 iters under ASAN/UBSAN, 0 crashes
Inherited from libzuptsdk → 169/169 + 750k fuzz iters
──────
Combined zupt + SDK 260 tests + 751k fuzz iters
Compatibility
- All v2.2.1 archives extract unchanged.
- Legacy
--pqkeyfiles continue to work. --pq-sdkflow unchanged.- New CI workflow does not affect runtime behavior.
Fixes after 2.2.2 (still v2.2.2)
- GUI install failures fixed. GUI deb dependencies now correctly reference
python3-pyqt6 | python3-pyside6(alternation) instead of nonexistentpython3-pyside6.qtwidgets. PySide6 is not in the default Debian/Ubuntu repositories; PyQt6 is. The GUI now imports either binding via try/except. - GUI auto-detects Qt binding at startup: PySide6 first (preferred), PyQt6 fallback. Prints instructive install message if neither is present, instead of crashing with ImportError.
- Man pages now include
--pq-sdkdocumentation. Bothdoc/zupt.1and the newdoc/zupt-gui.1cover SDK v2 mode, key generation with--sdk, both encryption workflows, FIPS/RFC standards, and IN ITI 35/2026 alignment. zupt help(CLI usage text) updated: SDK options grouped under Compress/Extract/Keygen sections with full descriptions, two example workflows (legacy + SDK v2 recommended).- Cross-platform packaging: deb script now arch-aware (amd64 →
x86_64-linux-gnu, arm64 →aarch64-linux-gnu, armhf, i386). Switched to xz compression for compatibility with older dpkg. - GUI rpm/AppImage builders added:
packaging/build-gui-rpm.shandpackaging/build-gui-appimage.sh. The AppImage uses system Python+Qt (small, ~50KB) instead of bundling Python (~80MB) — instructive runtime check if Qt binding missing. - GUI deb postinst refreshes desktop database and icon cache; postrm cleans them up.
[2.2.1] — 2026-04-27
Audit-driven hardening release. Six bugs found by code review and fixed; new audit test suite added.
Bugs found and fixed
-
Varint reader truncation (
src/zupt_format.c:146). The reader processed at most 9 continuation bytes, but a uint64 varint can span 10 bytes. Values above 2^63 would silently truncate. Reader now accepts up to 10 bytes and returns -1 if a 10th continuation byte is set, preventing accidental wraparound. -
Unchecked
fwritein extract path (src/zupt_format.c, six call sites: 1529, 1611, 1631, 1660, 1689, 1699). On a full disk or write error, the extractor reported success while files were corrupt. Eachfwritenow checks the return; partial writes set the per-fileberrflag, and the solid-file path returnsZUPT_ERR_IO. Found by reading the extract path with the question "what happens if the disk fills mid-extract?" -
mac_keyderived as a copy ofenc_key(src/zupt_crypto_sdk.c). The keyring'smac_keyslot was filled with the same 32 bytes asenc_key. SDK-mode AEAD doesn't actually use this slot — block authentication runs through the libzuptsdk path — but if any legacy fall-through ever readmac_key, it would have used the encryption key as a MAC key. Both keys are now derived from the session key via SHA3-256 with domain-separation strings ("ZUPT-SDK-ENC-KEY","ZUPT-SDK-MAC-KEY"). -
Length-overflow in LZ decoder (
src/zupt_lz.c:33).lz_read_extraaccumulated a length value with no upper bound. A crafted block with many0xFFextension bytes could overflowsize_t, wrapping back to a small value that passed the subsequentop + match_len > dst_lencheck, then the inner copy loop ran for many iterations. Capped accumulation at 2^32 with explicit overflow return; both call sites check. -
Dedup-ref recursion / out-of-bounds offset (
src/zupt_format.c, two sites). A malicious archive could place aDEDUP_REFblock whose offset pointed to itself, to a forward position, or to anotherDEDUP_REF. The first two would loop or seek to garbage; the third would not loop in the current implementation but is structurally invalid and is now rejected. The fix requiresref_off < cur_pos(refs always point backward to previously-written blocks) and rejectsref_blk.block_type == ZUPT_BLOCK_DEDUP_REF. -
Partial archive left on disk after encrypt-init failure. When
--pq-sdk pubkeywas given a non-existent or invalid public key,write_enc_headerreturnedZUPT_ERR_AUTH_FAILafter the archive header had already been written. The 64-byte stub was left on disk. Both compress paths nowunlink(output_path)before returning.
Added
tests/test_audit.sh— 10-check double-validated audit suite (every property tested via two independent paths). Categories: authenticated archives, format security, format compatibility, robustness.packaging/build-deb.sh— produceszupt_2.2.1_amd64.deb(CLI + libzuptsdk).packaging/build-rpm.sh— produces RPM viarpmbuild, falls back to SRPM-equivalent tarball whenrpmbuildis absent.packaging/build-appimage.sh— produces AppImage viaappimagetool, falls back to portable AppDir tarball.packaging/build-gui-deb.sh— produceszupt-gui_1.1.0_all.deb.
GUI changes (zupt-gui 1.1.0)
- New "Mode" panel in compress/extract tabs with SDK v2 checkbox (default on for new archives).
- Keygen tab gains "SDK v2 format" checkbox; when enabled, runs
zupt keygen --sdkand reports both private and public key paths. - Tooltip on each SDK checkbox explains the cryptographic difference (HKDF combiner + commitment + HPKE vs legacy XOR+SHA3-512).
- Existing
--pqlegacy flow preserved when SDK checkbox is unchecked.
Test results
make test → 9 passed, 0 failed
test_sdk.sh → 11 passed, 0 failed
test_audit.sh → 10 passed, 0 failed
TOTAL → 30 / 30
Inherited from libzuptsdk 2.1.5: 169 SDK tests + 750k mutation-fuzz iterations under ASAN/UBSAN.
Compatibility
- Existing v1/v2 archives extract unchanged.
- Legacy
--pqkeyfiles continue to work with the legacy combiner. - New
--pq-sdkkeyfiles use the SDK v2 path (incompatible with--pq, by design — this is what prevents mode confusion).
Post-release additions (still 2.2.1)
LICENSEfile added to repo root (AGPL-3.0 text was previously only insdk/LICENSE). Source tarball now contains it.make test-asan-runtarget wired into the zupt Makefile. Builds zupt with-fsanitize=address,undefined, runs all three test suites (run_quick.sh,test_sdk.sh,test_audit.sh) against the instrumented binary. All 30 tests pass cleanly under ASAN/UBSAN with zero memory errors.
[2.2.0] — 2026-04-27
zuptsdk integration release. Replaces zupt's legacy hybrid combiner (XOR+SHA3-512) with libzuptsdk's HKDF-SHA3-256 + key commitment + HPKE binding + anti-fault decap.
Added — SDK-backed crypto path
src/zupt_crypto_sdk.c— new module wrapping libzuptsdk for archive encryption.vendor/zuptsdk/— vendored libzuptsdk 2.1.5 (737KB shared lib + headers).- New encryption type IDs:
ZUPT_ENC_PQ_SDK_V2(0x03) andZUPT_ENC_PW_ARGON2(0x04). - New CLI flag
--pq-sdk <keyfile>for SDK-backed PQ encryption. zupt keygen --sdk -o key.privgenerates SDK-format keypair (writes bothkey.privandkey.priv.pub).- Argon2id replaces PBKDF2 for new password-mode archives (legacy reads still work).
Security improvements (vs zupt 2.1.7)
| Property | zupt 2.1.7 (legacy --pq) |
zupt 2.2.0 (--pq-sdk) |
|---|---|---|
| Hybrid combiner | XOR + SHA3-512 (Bindel-Brendel-Fischlin warns against XOR) | HKDF-SHA3-256 with domain separation |
| Key commitment | None | 32-byte HKDF-derived commitment tag |
| HPKE binding | None | RFC 9180 §5 context (suite + AAD bound) |
| Anti-fault decap | None | Double ML-KEM decap + CT compare |
| Password KDF | PBKDF2-SHA256 | Argon2id (RFC 9106 OWASP minimums) |
| AEAD | AES-256-CTR + HMAC-SHA256 | XChaCha20-Poly1305 (default) |
| Test coverage | 9 tests | 9 + 11 SDK = 20 + inherited 169 SDK tests |
Backward compatibility
- Legacy
zupt c --pq <key>path unchanged (XOR+SHA3-512 combiner). - Legacy
zupt x --pq <key>reads both old and new archives transparently — encryption type byte is checked. - Old keyfiles still work with old
--pq. New keyfiles (with.pub) work with--pq-sdk. - v1/v2 archive read path untouched.
Tests
tests/test_sdk.sh— 11 new tests: keygen, small/large roundtrip, wrong-key rejection, tamper rejection, legacy compat.- All 9 existing tests still pass.
- Total: 20 zupt tests + inherited 169 zuptsdk tests + 750k fuzz iters.
Build
make now requires vendor/zuptsdk/ to be present. The vendored libzuptsdk is shipped in the source tarball.
Linker resolves libzuptsdk.so.2 via absolute rpath to $(abspath vendor/zuptsdk). For installed builds, distros should LDFLAGS="-Wl,-rpath,/usr/lib" and install libzuptsdk separately (preferred) or embed via static link.
All notable changes to Zupt are documented in this file. Format follows Keep a Changelog.
[2.1.6] — 2026-04-22
Added — Archive Info Command (zupt info)
- New
zupt info <archive>subcommand: Shows archive metadata without needing a password. Displays format version, UUID, creation timestamp, file size, block count, and all flags (encrypted, PQ hybrid, solid, multithreaded, dedup, disk image). Useful for inspecting archives before decryption, triaging backups, and scripting. - Works on all archive types: plain, encrypted, PQ, dedup, disk images.
- Rejects non-archive files with a clear error message.
Added — Password Strength Warnings
- Weak password detection during
zupt compress -p: warns if password is shorter than 8 characters ("very short") or shorter than 12 characters with fewer than 3 character classes ("weak"). Uses character class analysis: uppercase, lowercase, digits, special characters. - Non-blocking: warning is informational only, compression proceeds. PBKDF2 with 600K iterations provides baseline protection even for weaker passwords.
Enhanced — zupt list Shows Dedup & PQ Flags
- Archive listing header now displays
| Dedup,| PQ,| Diskflags when present, making it immediately visible what features an archive uses.
Upgraded — VaptVupt 2.40.0 Codec
- VaptVupt 2.40.0 integrated (production hardening release). 55-case adversarial test suite, 10,200-case differential fuzzer, new
vv_xxh64.candvv_platform.h. - format_v2 enabled: 4-7% better compression ratio on binary data (ELF, shared libraries). All v2.33.0+ decoders read v2 frames transparently.
- VV_DECOMPRESS_SKIP_CHECKSUM: Decode skips redundant XXH64 verification since zupt's HMAC-SHA256 already authenticates compressed data. Up to 3.7x faster decode on post-encryption random data.
Added — Zupt GUI (PySide6 Desktop Application)
- Cross-platform graphical interface in
gui/subdirectory. Covers all zupt operations: key generation (generate + export public key), compress (all codecs, levels, dedup, solid, password, PQ), extract, verify, info, disk backup/restore. - System integration: Nemo right-click actions (Compress/Extract with Zupt), .zupt MIME type, desktop file.
- Packaging: .deb, .rpm, .whl, source tarball, NSIS Windows installer, AppImage, Flatpak configs.
- Dynamic version display from zupt binary (no hardcoded version strings). Window icon on all platforms.
Tests
- 97 total: 84 existing (70 core + 8 disk + 6 dedup) + 13 v2.1.6 (7 info, 4 password, 2 list flags). VV unit tests 11/11. ASAN clean.
[2.1.5] — 2026-04-12
Added — Block-Level Deduplication (--dedup)
- New
--dedup/-Dflag forzupt compressandzupt disk backup. Eliminates redundant data blocks before compression using XXH64 fingerprinting with full content verification on match. - New block type
ZUPT_BLOCK_DEDUP_REF(0x04): Reference blocks store an 8-byte offset to the original data block instead of the full block payload. A 4MB duplicate block becomes 8 bytes. - Hash table index: Open-addressing with linear probing, capped at 2M entries (~48MB RAM). 75% load factor limit. Secure wipe on free.
- Content verification: XXH64 fingerprint match is verified by block size comparison to prevent hash-collision corruption.
- Backward compatible: Archives without
--dedupare byte-identical to v2.1.4. Dedup reference blocks are handled transparently on extract/restore — no--dedupflag needed for reading. - New source file:
src/zupt_dedup.c(165 lines) — dedup context, hash table, ref block writer. - New global flag:
ZUPT_FLAG_DEDUP (1u << 7)— informational, set in archive header. - Extract paths updated: Both
zupt_extract_archive()(single-threaded) andzupt_disk_restore()handleDEDUP_REFblocks by seeking to the referenced offset, reading+decompressing the original block, then seeking back.
Tests
- 84 total: 70 core + 8 disk + 6 dedup (plain, password, PQ, PQ+password, disk, no-duplicates). ASAN clean.
[2.1.4] — 2026-04-11
Fixed — CodeQL Security Alerts (4/4 resolved)
- Alert #1 & #2: TOCTOU filesystem race in
get_device_size()(High). The function calledstat(path)to classify the file type, thenopen(path)to read it — between those two calls an attacker could swap the path to a different file. Fix: open the fd first withopen(), then classify viafstat(fd). The fd is stable and cannot be swapped. - Alert #3: Dead-store
memsetinzupt_x25519()(High). Thememset(e, 0, 32)call to wipe the clamped scalar was the last use ofebefore the function returned, so the compiler could legally optimize it away (and some do at-O2). Fix: volatile pointer loop (volatile uint8_t *ve = e; for(...) ve[i] = 0;) which the compiler must emit. - Alert #4: TOCTOU filesystem race in
zupt_disk_restore()(High). Restore calledstat(target_path)to check for block devices, thenopen(target_path)— same race window as alerts #1/#2. Fix: open fd first, thenfstat(fd)to classify, thenfcntl(fd, F_SETFL, O_SYNC)for block devices.
Tests
- 78 total: 70 core + 8 disk. ASAN + UBSan clean.
[2.1.3] — 2026-04-11
Fixed — LZHP Prediction Encoding Missing in Disk Backup (data corruption)
- Root cause:
zupt_disk_backup()LZHP compression path skipped thezupt_predict_encode()step. When byte prediction was active (pred_active=1), it stored the prediction table and wrotecbuf[0] = 0x01, but then compressed the raw block data instead of the prediction-encoded data. On restore,decompress_block()correctly appliedzupt_predict_decode()to the decompressed output, producing corrupted data. Checksum mismatch on block 0 for any block with structured content (ext4 metadata, NTFS headers, partition tables). - Impact: ALL disk backups using LZHP codec (default on CPUs without AVX2) on non-random data were silently corrupted. VaptVupt codec was unaffected (no prediction path). Random/incompressible data was unaffected (prediction benefit < threshold →
pred_active=0). - Fix: Added
zupt_predict_encode(rbuf, transformed, nread, pred)beforezupt_lzh_compress(), matching the correct path inzupt_format.c(lines 557–563). Allocated temporary buffer for prediction-encoded data, freed after compression.
Fixed — Spurious SOLID Flag on Disk Archives
- Disk backup no longer sets
ZUPT_FLAG_SOLIDin the archive header. Disk images are independent per-block archives, not solid streams. The SOLID flag causedzupt_extract_archive()to take the wrong code path if a disk archive was ever parsed by the extract function.
Fixed — Shared Encryption Header (eliminates all format mismatches)
- Extracted
write_enc_header()fromzupt_format.cas a shared non-static function. ALL three encryption write paths —zupt_compress_files(),zupt_compress_solid(), andzupt_disk_backup()— now call the same function. - Solid compress now supports PQ encryption.
zupt_w8(),zupt_w16le(),zupt_w64le()made non-static and declared inzupt.h.
Fixed — Block Device Restore I/O
- Restore uses POSIX raw I/O (
open()+write()loop) withO_SYNCfor block devices,fsync()+sync()before close.
Fixed — Termux/Android Build
- Arch-safety guard uses
$(CC) -dumpmachinefor host detection. Falls back touname -m.
Tests
- 78 total: 70 core + 8 disk (including LZHP+PQ+password on ext4 — the exact failing case). ASAN + UBSan clean.
[2.1.2] — 2026-04-06
Added — Full-Disk Backup/Restore
zupt disk backup— streams a raw block device or file in 4MB chunks, compresses each block with the selected codec (VaptVupt default), detects all-zero (sparse) blocks and stores them with near-zero overhead. Supports password encryption (-p), post-quantum encryption (--pq), compression level override (-l 1-9), and codec selection (--vv,--lzhp). Real-time progress bar with throughput on stderr.zupt disk restore— reads a disk image archive block-by-block, decrypts + decompresses each block, validates per-block XXH64 checksums, and writes sequentially to the target device or file. Rejects wrong passwords/keys immediately on first block failure.ZUPT_FLAG_DISK_IMAGE (1u << 6)— new global flag in the archive header.zupt disk restorevalidates this flag and rejects non-disk archives. Standardzupt extractrejects disk archives with a clear error message.src/zupt_disk.c— 530 lines. Portable device size detection:BLKGETSIZE64on Linux,DKIOCGETBLOCKCOUNTon macOS,lseek(SEEK_END)fallback on FreeBSD/generic. 8-byte-wide sparse block detection.- CLI:
zupt disk backup [OPTIONS] <output.zupt> <device_or_file>/zupt disk restore [OPTIONS] <archive.zupt> <target>
Tests
- 77 tests total: 11 VV unit + 13 NIST/RFC vectors + 22 regression + 14 multi-threaded + 10 post-quantum + 7 disk backup (normal, encrypted, PQ, sparse, LZHP, extreme, wrong-password rejection). ASAN + UBSan clean across all paths.
[2.1.1] — 2026-04-06
Fixed — Multi-Architecture Build
- Stale object files removed from distribution. Previous tarballs shipped pre-compiled x86_64
.ofiles. On aarch64 (Termux, Raspberry Pi, etc.) the linker failed withld.lld: error: src/zupt_xxh.o is incompatible with aarch64linux. All.ofiles now excluded from release tarballs. - Arch-safety guard in Makefile. Detects pre-compiled
.ofiles from a different architecture viafile(1)and auto-removes them before linking. Prevents silent link failures if stale objects are accidentally present. - Termux/Android compatibility. Default compiler changed from
gcctocc(Termux ships clang).-lpthreadskipped on Android/Termux (bionic libc has pthreads built-in, detected viauname -o). sys/syscall.hinclude moved to file top inzupt_crypto.c. Was inside function body (non-standard C, rejected by some compilers).
Fixed — Undefined Behavior
- Keccak ROL64 shift-by-64 UB.
ROL64(x, 0)expanded to(x >> 64)which is undefined behavior in C. The Keccak rotation table hasKECCAK_ROT[0] = 0, triggering this on every Keccak-f[1600] call (SHA3-256, SHA3-512, SHAKE-128, SHAKE-256, ML-KEM-768). Fix:ROL64now returnsxunchanged whenn == 0. Confirmed zero UBSan violations across all PQ paths.
Tests
- 70/70: 11 VV + 13 NIST + 22 regression + 14 MT + 10 PQ. ASAN + UBSan clean (zero violations).
[2.1.0] — 2026-04-05
Upgraded — VaptVupt 1.4.0 Codec
- Cross-block dictionary carry — hash chain now spans block boundaries. The encoder passes absolute positions to
compress_block()so matches can reference data from previous blocks. Large structured files (7MB logs) compress 5.73:1 instead of per-block independent ratios. The decoder accepts cross-block offsets via adst_baseparameter threaded through all decode functions. - Context model decode prefetch —
__builtin_prefetchin the order-1 context ANS decode loop hides L2/L3 latency for the 4MB context tables. Extreme-mode decode throughput improved significantly on cache-constrained systems. - Faster adaptive window trial — greedy depth=4 on 256KB sample instead of full lazy parse on entire first block. Encode speed improved 2.6× with same ratio decisions.
- Zupt integration API — new
vvz_compress/vvz_decompress/vvz_compress_boundwrappers (vaptvupt_api.h/vaptvupt_api.c) simplify codec dispatch with backup-optimized defaults.
Changed
zupt_format.ccompress paths (normal, solid) now usevvz_compress()API instead of rawvv_compress()with manual option setup.zupt_format.cdecompress path now usesvvz_decompress()API.- Version bumped to 2.1.0.
Performance (balanced mode, vs gzip-9)
| File Type | v2.1.0 | gzip-9 | vs gzip |
|---|---|---|---|
| Source code (531K) | 59.5:1 | 51.7:1 | +15% better |
| JSON (232K) | 10.7:1 | 8.8:1 | +21% better |
| XML markup (641K) | 18.1:1 | 14.6:1 | +24% better |
| Long-range (800K) | 5.7:1 | 1.4:1 | +307% better |
| Logs 7MB (7.5MB) | 5.7:1 | 7.5:1 | gap 24% |
Tests
- 70/70: 11 VV + 13 NIST + 22 regression + 14 MT + 10 PQ. ASAN clean.
[2.0.0] — 2026-04-05
Added — VaptVupt 1.1.0 Codec Integration
- VaptVupt codec integrated as
0x0010— LZ77 + tANS entropy + AVX2 SIMD decode. - Three compression modes: Ultra-Fast (greedy), Balanced (lazy + 4-way ANS), Extreme (lazy-2 + order-1 context).
- Rep-match offset coding — 3 recent offsets tracked (like zstd), saves 10–15 bits per repeated match.
- Adaptive window selection — trial-compresses at wlog=16 vs wlog=20, picks larger window only if ≥3% improvement.
- CLI flags
--vv/--vaptvuptto select VaptVupt codec. - CLI flag
--lzhpto explicitly select Zupt-LZHP codec. - VaptVupt source files with dual MIT + Apache-2.0 headers.
vv_xxh64aliased tozupt_xxh64via macro (no duplicate symbol).- Wired into compress (single-thread, multi-thread, solid) and decompress paths.
- 11 VaptVupt unit tests + 6 regression tests (T13–T18).
Added — Auto Codec Detection
ZUPT_CODEC_AUTO— hardware-aware default codec selection:- x86_64 with AVX2: VaptVupt (inline AVX2 SIMD decode, ~2–3 GB/s).
- aarch64 with NEON: VaptVupt (NEON SIMD decode path).
- All other architectures: Zupt-LZHP (scalar decoder, no SIMD dependency).
zupt_resolve_auto_codec()checks compile-time flags (__AVX2__,__ARM_NEON) and runtime CPUID.- Decompression is universal — any archive extracts on any architecture regardless of codec.
- Users can override with
--vv(force VaptVupt) or--lzhp(force LZHP).
Fixed — Jasmin Assembly
- AES-NI stack offset bug fixed: replaced
stack u128[15]with 15 individualstack u128variables to avoid jasminc byte-offset indexing. Round keys now at correct 16-byte aligned offsets. - X25519 fe_cswap wired: Jasmin swaps first 4 limbs (32 bytes), C handles 5th limb.
- All 5 Jasmin functions now active:
zupt_mac_verify_ct,zupt_ct_select_32,zupt_fe_cswap,zupt_aes256_blk,zupt_aes256_ctr4. - SIGILL fix: AVX detection with OSXSAVE/XCR0 check. The Jasmin AES assembly uses VEX-encoded instructions (
vaesenc,vmovdqu,vpxor) which require AVX — not just AES-NI. Previous dispatch only checkedhas_aesni, causing SIGILL on CPUs with AES-NI but without AVX or without OS XSAVE support. Now checkshas_aesni && has_avxwith proper XGETBV XCR0 validation. - Added
has_avxfield tozupt_cpu_features_twith correct detection: CPUID ECX[28] (AVX) + ECX[27] (OSXSAVE) + XCR0 bits 1+2.
Fixed — VaptVupt Codec Bugs
copy_match_scalaroverlap corruption (vv_simd.c): 8-byte bulk copy was used for offsets 4–7, where source overlaps destination by more than the copy stride. Thememcpyread-then-write semantics don't correctly replicate the overlapping pattern. Fix: byte-by-byte for offsets < 8 (was < 4). This caused silent data corruption on inputs with short-offset matches near the output buffer tail.vva_encode_sequencesheap overflow (vv_ans.c): litlen varint buffer allocated asnseq * 5 + 1bytes, but individual literal lengths in solid mode can reach 1 MB, requiring up toceil(litlen/255) + 1bytes per varint. Fix: compute exact bound from actual litlen values. This caused heap corruption and abort (malloc(): invalid size) on large solid-mode archives.
Added — ACSL Formal Annotations
- 19 security-critical functions annotated with complete
requires/ensures/assignsACSL contracts. - Covers: SHA-256, HMAC, PBKDF2, AES-256-CTR, key derivation, encrypt/decrypt, hybrid KEM, SHA3, SHAKE, ML-KEM-768, X25519, secure_wipe.
- Target:
frama-c -wp -wp-rte -wp-model Typed+Cast.
Added — Security Hardening
- mlock() for key material — prevents swap to disk (Linux/BSD/Windows).
- Buffer canaries on
zupt_keyring_t—canary_head/canary_taildetect overflow, abort on corruption. - Always-decrypt timing mitigation —
zupt_decrypt_buffer()always decrypts even on MAC failure (then wipes), preventing timing oracle. - AFL++ fuzzing harnesses —
fuzz_decompress.c(archive format) andfuzz_vv_decompress.c(VaptVupt codec).make fuzz-build.
Added — Performance
- AES-NI 4-block pipeline —
zupt_aes256_ctr4interleaves 4 counter blocks per AES round for pipeline saturation. - Multi-threaded decompression — non-solid extract dispatches blocks to N worker threads via
zpar_ctx_tinfrastructure. - Adaptive compression —
zupt_detect_filetype()identifies 16+ file formats by magic bytes; already-compressed files get STORE. - Benchmark harness —
zupt bench --comparetests all codecs + auto-detects gzip/lz4/zstd.
Changed — Multi-Architecture Support
- Jasmin CT assembly: x86_64 only (C fallback on all others).
- AVX2 SIMD decode: x86_64 only. NEON decode: aarch64. Scalar fallback: everywhere.
Tests
- 70 tests total: 11 VV unit + 13 NIST/RFC vectors + 22 regression + 14 multi-threaded + 10 post-quantum.
- ASAN clean across all modes (normal, encrypted, solid, threaded, PQ).
- All 5 Jasmin symbols linked (confirmed via
nm).
[1.5.0] — 2026-03-28
Added — Jasmin Assembly Integration (Sprint 1)
zupt_mac_verify_ctJasmin assembly linked intozupt_decrypt_buffer(). Replaces the C XOR accumulation loop for HMAC-SHA256 comparison. 4×u64 unrolled XOR, proven constant-time by Jasmin type system. Symbol confirmed active vianm:T zupt_mac_verify_ct.zupt_ct_select_32Jasmin assembly linked intozupt_mlkem768_decaps(). Replaces the Ccmov()function for Fujisaki-Okamoto implicit rejection key selection. 4×u64 masked select, proven constant-time. Symbol confirmed active vianm:T zupt_ct_select_32.include/zupt_jasmin.h— extern declarations for all Jasmin functions with ABI documentation.#ifdef ZUPT_USE_JASMINdispatch guards inzupt_crypto.candzupt_mlkem.cwith clean C fallback.- Makefile auto-detects
jasmin/*.sfiles, assembles to.o, links into binary, sets-DZUPT_USE_JASMIN.
Not Wired (documented, requires upstream fixes)
zupt_fe_cswap(X25519): Jasmin uses 4×u64 limbs, C uses 5×u51-bit — incompatible layout. C fallback active.zupt_aes256_blk(AES-NI): Assembly has stack offset bug ([rsp+1]instead of[rsp+16]). C table-based AES active.
Changed
- Version: 1.4.0 → 1.5.0.
cmov()inzupt_mlkem.cguarded with#ifndef ZUPT_USE_JASMIN.- MAC comparison return type widened from
uint8_ttouint64_tto match Jasmin signature.
Security
- 53/53 tests pass with Jasmin linked. 13/13 NIST vectors. ASAN clean. Zero warnings.
[1.4.0] — 2026-03-28
Fixed — Jasmin Parse Errors (jasminc 2026.03.0)
All 4 .jazz files rewritten to fix compilation errors:
zupt_mac_verify.jazz:diff |= a ^ b— compound XOR+OR not a single x86-64 op. Split intotmp = a; tmp ^= b; diff |= tmp.zupt_mlkem_select.jazz:out.[i] = (8u)sel—reg ptris read-only. Changed toreg u64 out_ptrwith raw pointer writes.zupt_x25519_fe.jazz:a.[i] = ta ^ diff— same const-ptr write. Changed toreg u64 a_ptr.zupt_aes_ctr.jazz: Memory syntax(u128)[ptr]→u128[ptr]→[ptr]— all wrong. Correct:key.[0]viareg ptr u128[N]for reads;stack u128[15]for writes; bare[ptr + 0]for u64-width.- Uninitialized variable warning:
#VPXOR(zero, zero)→wipe = rk.[z]; wipe ^= wipe; rk.[z] = wipe.
Changed
- Removed all
-CTflag references (does not exist in jasminc 2026.03.0). - CT enforced by Jasmin type system during normal compilation.
- Safety:
jasminc -arch x86-64 -checksafety. - All compound expressions split into separate register operations.
- All output parameters changed from
reg ptrtoreg u64raw pointers. - Byte-level access avoided: 4×u64 instead of 32×u8.
[1.3.0] — 2026-03-28
Added
include/zupt_acsl.h— ACSL predicates:ValidBuffer,ValidWriteBuffer,Separated2,KeyWiped,ValidKey.SECURITY_REVIEW.md— 8-section security review with per-function CT analysis table.jasmin/README.jazz.md— build instructions, CT verification explanation, error history.
Fixed
- First round of Jasmin syntax fixes (partial — completed in v1.4.0).
[1.2.0] — 2026-03-28
Added — CPUID Runtime Detection
src/zupt_cpuid.c+include/zupt_cpuid.h— runtime detection of AES-NI, PCLMUL, AVX2, SSE4.1 via CPUID. Supports GCC/Clang, MSVC, and inline assembly fallback.zupt_detect_cpu()called at program start. Globalzupt_cpustruct for dispatch.
Added — Jasmin Source Files (initial)
- 4
.jazzfiles created for AES-CTR, MAC verify, X25519, ML-KEM select. - Note: All had parse errors — fixed in v1.3.0–v1.4.0.
[1.1.0] — 2026-03-28
Fixed — Critical Cryptographic Bugs
- X25519 Montgomery formula (
zupt_x25519.c):AA + 121666*E→BB + 121666*E. The doubling formula was algebraically wrong. DH exchanges produced consistently wrong but matching values, so PQ archives worked. RFC 7748 test vectors exposed the bug. All X25519 in v0.7.0–v1.0.0 was not interoperable with any other implementation. - Dead
match_cost()(zupt_lzh.c): Defined but never called. Removed (Clang-Wunused-function). - ML-KEM
const polyvecwarnings: C11 doesn't support multi-level const for arrays-of-arrays. Removedconst(matches pqcrystals reference). __int128pedantic warning: Wrapped with#pragma GCC diagnostic push/pop.
Added
tests/test_vectors.c— 13 NIST/RFC test vectors: SHA-256 (3), HMAC-SHA256 (2), SHA3-256 (2), SHAKE-128 (1), X25519 (2), ML-KEM-768 (2), XXH64 (1).
Changed
- Zero warnings on GCC + Clang with
-Wall -Wextra -Wpedantic.
[1.0.0] — 2026-03-21
Stable Release
- Archive format frozen at v1.4.
FORMAT_STABLEflag set. Future changes require v2.0. - Documentation: FORMAT.md, AUDIT.md, FUZZING.md, SECURITY.md.
- License: GPL-3.0 → MIT.
Fixed — ML-KEM-768 Bugs (5 critical)
poly_basemulOOB:zetas[64+i]accessed past 128-entry array. Fixed to 64 iterations.- Missing
poly_tomont()in keygen: Public key in wrong Montgomery domain. - Inverted
cmovin FO decaps: C integer promotion caused rejection key selected on valid ciphertext. Fixed:(-(int64_t)diff) >> 63. inv_nttwrong zetas table: Separate wrong table. Fixed: reusezetas[], k counts 127→0.- PQ nonce mismatch: Encrypt/decrypt independently generated nonces. Fixed: store in header.
Added — Post-Quantum Hybrid Encryption (v0.7.0)
- ML-KEM-768 (FIPS 203): ~658 lines pure C11. NTT, Barrett/Montgomery, CBD, FO transform.
- X25519 (RFC 7748): ~270 lines. Montgomery ladder, constant-time fe_cswap.
- Keccak-f[1600]: SHA3-256/512, SHAKE-128/256. ~215 lines.
- Hybrid KEM:
SHA3-512(ml_ss XOR x25519_ss ‖ transcript). Secure if EITHER holds. zupt keygensubcommand,--pq <keyfile>flag.- Key file format: ZKEY magic, ML-KEM pk(1184B) + X25519 pk(32B) + optional sk + XXH64.
- 10-test PQ suite.
- Format v1.3 → v1.4 with
enc_typedispatch byte.
Added — Multi-Threaded Compression (v0.6.0)
-t <N>flag. Batch-parallel pipeline. 14-test MT suite.- Solid mode falls back to N=1 (shared LZ context).
Added — Security Hardening (v0.5.1)
- 16 bug fixes: Huffman Kraft violation (data corruption), heap-buffer-overflows, removed
rand()fallback, constant-time MAC, secure key wipe, LE serialization, realloc checks, empty file checksum.
Core Features (v0.1.0–v0.4.0)
- LZ77+Huffman compression (1MB window, near-optimal parsing).
- AES-256-CTR + HMAC-SHA256 authenticated encryption.
- PBKDF2-SHA256 (600,000 iterations).
- Per-block XXH64 integrity. Recursive directory backup. Solid mode.
Summary
| Version | Key Change | Tests |
|---|---|---|
| 2.1.4 | Shared write_enc_header() eliminates all format mismatches, solid PQ support, block device O_SYNC |
78 PASS |
| 2.1.3 | Disk restore rewritten — uses shared block I/O, fixes checksum mismatch with all encryption formats | 77 PASS |
| 2.1.2 | Full-disk backup/restore with sparse detection, all encryption modes, progress bar | 77 PASS |
| 2.1.1 | Termux/Android build fix, arch-safety guard, Keccak UB fix, no stale .o in tarballs | 70 PASS |
| 2.1.0 | VaptVupt 1.4.0: cross-block dictionary, context prefetch, faster adaptive window, integration API | 70 PASS |
| 2.0.0 | VaptVupt 1.1.0 codec, auto codec detection, all 5 Jasmin wired, AVX SIGILL fix, multi-arch, copy_match fix, litlen overflow fix | 70 PASS |
| 1.5.0 | Jasmin assembly linked: MAC verify + ML-KEM select active in binary | 53+13 PASS |
| 1.4.0 | All 4 .jazz files compile on jasminc 2026.03.0 |
53+13 PASS |
| 1.3.0 | ACSL predicates, security review, partial Jasmin fixes | 53+13 PASS |
| 1.2.0 | CPUID detection, Jasmin source files (with errors) | 53+13 PASS |
| 1.1.0 | X25519 BB formula fix, 13 NIST/RFC test vectors | 53+13 PASS |
| 1.0.0 | Format frozen v1.4, ML-KEM bugs fixed, MIT license | 40 PASS |
© 2026 Cristian Cezar Moisés — AGPL-3.0-or-later