This commit is contained in:
Cristian Cezar Moisés 2026-05-01 09:58:47 -03:00
commit e5f5d32aab
124 changed files with 11892 additions and 2461 deletions

View file

@ -1,7 +1,8 @@
/*
* Zupt v2.0.0 AFL++ Fuzzing Harness: Archive Decompression
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later (commercial: sac@securityops.co)
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* Zupt v2.0.0 AFL++ Fuzzing Harness: Archive Decompression
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Reads a fuzzed .zupt archive from stdin, attempts to extract it.
* Catches crashes, buffer overflows, and undefined behavior.

BIN
tests/fuzz_format Executable file

Binary file not shown.

157
tests/fuzz_format.c Normal file
View file

@ -0,0 +1,157 @@
/* zupt format parser fuzz harness
*
* Mutates valid archives and feeds them to the listing/extract path
* under ASAN/UBSAN. Any crash, leak, or sanitizer error is a finding.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#define _DEFAULT_SOURCE 1
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
/* Simple xorshift64 PRNG — deterministic, fast */
static uint64_t rng_state = 0xc0ffeebabe5050ULL;
static uint64_t rng(void) {
uint64_t x = rng_state;
x ^= x << 13; x ^= x >> 7; x ^= x << 17;
return rng_state = x;
}
static void mutate(uint8_t *buf, size_t n) {
if (n == 0) return;
int op = rng() % 5;
switch (op) {
case 0: { /* byte flip */
size_t i = rng() % n;
buf[i] ^= 1u << (rng() % 8);
break;
}
case 1: { /* random byte set */
size_t i = rng() % n;
buf[i] = (uint8_t)(rng() & 0xFF);
break;
}
case 2: { /* zero a 16-byte run */
if (n < 16) return;
size_t off = rng() % (n - 16);
memset(buf + off, 0, 16);
break;
}
case 3: { /* set 0xFF run */
if (n < 16) return;
size_t off = rng() % (n - 16);
memset(buf + off, 0xFF, 16);
break;
}
case 4: { /* swap two bytes */
if (n < 2) return;
size_t i = rng() % n;
size_t j = rng() % n;
uint8_t t = buf[i]; buf[i] = buf[j]; buf[j] = t;
break;
}
}
}
/* Run the zupt binary on a given archive file. We use fork+exec because
* any crash in zupt would otherwise take down the harness; the parent
* just records the exit status. ASAN/UBSAN errors return non-zero. */
static int run_zupt(const char *zupt_path, const char *archive,
const char **flag, int with_pq) {
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
/* child */
int devnull = open("/dev/null", O_WRONLY);
if (devnull >= 0) {
dup2(devnull, 1); dup2(devnull, 2);
close(devnull);
}
if (with_pq) {
execl(zupt_path, "zupt", "list", "--pq-sdk", "/tmp/_fuzz.priv",
archive, (char*)NULL);
} else {
execl(zupt_path, "zupt", "list", archive, (char*)NULL);
}
_exit(127);
(void)flag;
}
int status;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) return -2; /* crash! */
return WEXITSTATUS(status);
}
int main(int argc, char **argv) {
int n_iters = argc > 1 ? atoi(argv[1]) : 1000;
const char *zupt_path = argc > 2 ? argv[2] : "./zupt";
const char *seed = argc > 3 ? argv[3] : "/tmp/_fuzz_seed.zupt";
if (access(seed, R_OK) != 0) {
fprintf(stderr, "Seed archive not found at %s\n", seed);
fprintf(stderr, "Build seed first: ./zupt c %s some_file.txt\n", seed);
return 1;
}
FILE *f = fopen(seed, "rb");
if (!f) return 1;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *seed_data = malloc((size_t)sz);
if (!seed_data || fread(seed_data, 1, (size_t)sz, f) != (size_t)sz) {
fclose(f); free(seed_data); return 1;
}
fclose(f);
fprintf(stderr, "Fuzzing zupt format parser: %d iters, seed=%ld bytes\n",
n_iters, sz);
int crashes = 0, errors = 0, accepts = 0;
char fuzzfile[64] = "/tmp/_fuzz_archive.zupt";
double t0 = (double)clock() / CLOCKS_PER_SEC;
for (int i = 0; i < n_iters; i++) {
/* Copy seed, apply 1-5 random mutations */
uint8_t *buf = malloc((size_t)sz);
if (!buf) break;
memcpy(buf, seed_data, (size_t)sz);
int n_mut = 1 + (rng() % 5);
for (int m = 0; m < n_mut; m++) mutate(buf, (size_t)sz);
FILE *out = fopen(fuzzfile, "wb");
if (!out) { free(buf); continue; }
fwrite(buf, 1, (size_t)sz, out);
fclose(out);
free(buf);
int rc = run_zupt(zupt_path, fuzzfile, NULL, 0);
if (rc == -2) crashes++;
else if (rc != 0) errors++;
else accepts++;
if (i > 0 && i % 100 == 0)
fprintf(stderr, " [%d/%d] crashes=%d errors=%d accepts=%d\r",
i, n_iters, crashes, errors, accepts);
}
double dt = (double)clock() / CLOCKS_PER_SEC - t0;
unlink(fuzzfile);
free(seed_data);
fprintf(stderr, "\n\nFuzz results (%d iterations, %.1fs):\n", n_iters, dt);
fprintf(stderr, " CRASHES (signal): %d <-- bugs if non-zero\n", crashes);
fprintf(stderr, " errors (rejected): %d (expected, parser working)\n", errors);
fprintf(stderr, " accepts: %d (mutations that didn't break magic/size)\n", accepts);
return crashes > 0 ? 1 : 0;
}

View file

@ -1,7 +1,8 @@
/*
* Zupt v2.0.0 AFL++ Fuzzing Harness: VaptVupt Codec
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later (commercial: sac@securityops.co)
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* Zupt v2.0.0 AFL++ Fuzzing Harness: VaptVupt Codec
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Reads fuzzed VaptVupt frame data from stdin, attempts decompression.
* Tests the VaptVupt codec directly (bypassing Zupt archive format).

View file

@ -1,4 +1,6 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# ZUPT v2.0.0 — Comprehensive Regression Test Suite
# Covers: normal, solid, encrypted, edge cases, VaptVupt codec
# Run: sh tests/regression.sh

View file

@ -1,4 +1,6 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
set +e
Z="./zupt"; T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
mkdir -p "$T/d"; echo "hello" > "$T/d/a.txt"

78
tests/test_arg_order.sh Executable file
View file

@ -0,0 +1,78 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Regression test for argument order in extract/list/test commands.
# Bug #15 (v2.2.2): options after the positional archive argument were
# silently dropped. e.g. `zupt x arch.zupt -o out` ignored `-o out`.
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
chk() {
if [ $? -eq 0 ]; then echo "$1"; PASS=$((PASS+1))
else echo "$1"; FAIL=$((FAIL+1)); fi
}
echo " [Argument order regression — extract/list/test]"
# Setup
mkdir d
echo "secret content $(date +%s%N)" > d/file.txt
"$ZUPT_BIN" c arch.zupt d/file.txt > /dev/null 2>&1
"$ZUPT_BIN" c -p mypw arch_pw.zupt d/file.txt > /dev/null 2>&1
"$ZUPT_BIN" keygen -o k.key > /dev/null 2>&1
"$ZUPT_BIN" keygen --pub -o p.key -k k.key > /dev/null 2>&1
"$ZUPT_BIN" c --pq p.key arch_pq.zupt d/file.txt > /dev/null 2>&1
# P1: extract -o after archive
mkdir out1
"$ZUPT_BIN" x arch.zupt -o out1 > /dev/null 2>&1
[ -f out1/d/file.txt ] && diff -q d/file.txt out1/d/file.txt > /dev/null
chk "extract: -o after archive"
# P2: extract -o before archive
mkdir out2
"$ZUPT_BIN" x -o out2 arch.zupt > /dev/null 2>&1
[ -f out2/d/file.txt ] && diff -q d/file.txt out2/d/file.txt > /dev/null
chk "extract: -o before archive"
# P3: extract password options after archive
mkdir out3
"$ZUPT_BIN" x arch_pw.zupt -p mypw -o out3 > /dev/null 2>&1
[ -f out3/d/file.txt ] && diff -q d/file.txt out3/d/file.txt > /dev/null
chk "extract: -p AND -o after archive"
# P4: extract --pq after archive
mkdir out4
"$ZUPT_BIN" x arch_pq.zupt --pq k.key -o out4 > /dev/null 2>&1
[ -f out4/d/file.txt ] && diff -q d/file.txt out4/d/file.txt > /dev/null
chk "extract: --pq after archive"
# P5: list -p after archive (encrypted archive lists files)
out=$("$ZUPT_BIN" l arch_pw.zupt -p mypw 2>&1)
echo "$out" | grep -q "file.txt"
chk "list: -p after archive"
# P6: list -p before archive
out=$("$ZUPT_BIN" l -p mypw arch_pw.zupt 2>&1)
echo "$out" | grep -q "file.txt"
chk "list: -p before archive"
# P7: test -p after archive
out=$("$ZUPT_BIN" t arch_pw.zupt -p mypw 2>&1)
echo "$out" | grep -q "0 failed"
chk "test: -p after archive"
# P8: test -p before archive
out=$("$ZUPT_BIN" t -p mypw arch_pw.zupt 2>&1)
echo "$out" | grep -q "0 failed"
chk "test: -p before archive"
echo
echo " ───────────────────────────────────────"
echo " Argument order regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]

137
tests/test_audit.sh Executable file
View file

@ -0,0 +1,137 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# zupt audit test suite — double-validated security checks for zupt 2.2+
# Each property is checked via TWO independent paths.
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
DCHK() {
local name="$1" a="$2" b="$3"
if [ "$a" = "$b" ] && [ "$a" = "1" ]; then
echo "$name (A=$a B=$b agree)"; PASS=$((PASS+1))
else
echo "$name (A=$a B=$b disagree)"; FAIL=$((FAIL+1))
fi
}
# Setup: SDK keys
"$ZUPT_BIN" keygen --sdk -o k.priv > /dev/null 2>&1
"$ZUPT_BIN" keygen --sdk -o other.priv > /dev/null 2>&1
"$ZUPT_BIN" keygen -o legacy.key > /dev/null 2>&1
echo " [A. Authenticated archives]"
# A1. Wrong key rejected: SDK key vs SDK archive (path A) + Legacy key vs SDK archive (path B)
echo "data" > input.txt
"$ZUPT_BIN" c --pq-sdk k.priv.pub a.zupt input.txt > /dev/null 2>&1
mkdir -p ea && (cd ea && "$ZUPT_BIN" x --pq-sdk ../other.priv ../a.zupt > /dev/null 2>&1)
A=$([ ! -f ea/input.txt ] && echo 1 || echo 0)
mkdir -p eb && (cd eb && "$ZUPT_BIN" x --pq legacy.key ../a.zupt > /dev/null 2>&1)
B=$([ ! -f eb/input.txt ] && echo 1 || echo 0)
DCHK "Wrong key rejected (SDK key + legacy key paths)" "$A" "$B"
# A2. Tamper at byte position N detected (path A: pos 200) (path B: pos at end)
cp a.zupt t1.zupt; cp a.zupt t2.zupt
python3 -c "
b = bytearray(open('t1.zupt','rb').read())
b[200] ^= 1
open('t1.zupt','wb').write(bytes(b))" 2>/dev/null
python3 -c "
b = bytearray(open('t2.zupt','rb').read())
b[len(b)-50] ^= 1
open('t2.zupt','wb').write(bytes(b))" 2>/dev/null
mkdir -p t1e && (cd t1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t1.zupt > /dev/null 2>&1)
mkdir -p t2e && (cd t2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t2.zupt > /dev/null 2>&1)
A=$([ ! -f t1e/input.txt ] && echo 1 || echo 0)
B=$([ ! -f t2e/input.txt ] && echo 1 || echo 0)
DCHK "Tamper detected at any byte position" "$A" "$B"
echo " [B. Format security]"
# B1. Zero-byte file (path A) + 1-byte file (path B): both must roundtrip
> empty.txt
echo -n "x" > one.txt
"$ZUPT_BIN" c --pq-sdk k.priv.pub e.zupt empty.txt > /dev/null 2>&1
"$ZUPT_BIN" c --pq-sdk k.priv.pub o.zupt one.txt > /dev/null 2>&1
mkdir -p eex && (cd eex && "$ZUPT_BIN" x --pq-sdk ../k.priv ../e.zupt > /dev/null 2>&1)
mkdir -p oex && (cd oex && "$ZUPT_BIN" x --pq-sdk ../k.priv ../o.zupt > /dev/null 2>&1)
A=$([ -f eex/empty.txt ] && [ ! -s eex/empty.txt ] && echo 1 || echo 0)
B=$([ -f oex/one.txt ] && [ "$(cat oex/one.txt)" = "x" ] && echo 1 || echo 0)
DCHK "Edge-size files (0/1 byte) roundtrip" "$A" "$B"
# B2. 1MB random file (path A) + structured-data 1MB (path B)
dd if=/dev/urandom of=big_a.bin bs=1M count=1 2>/dev/null
python3 -c "open('big_b.bin','wb').write(b'A'*1024*1024)"
"$ZUPT_BIN" c --pq-sdk k.priv.pub ba.zupt big_a.bin > /dev/null 2>&1
"$ZUPT_BIN" c --pq-sdk k.priv.pub bb.zupt big_b.bin > /dev/null 2>&1
mkdir -p ba_e && (cd ba_e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../ba.zupt > /dev/null 2>&1)
mkdir -p bb_e && (cd bb_e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../bb.zupt > /dev/null 2>&1)
A=$(diff -q big_a.bin ba_e/big_a.bin > /dev/null 2>&1 && echo 1 || echo 0)
B=$(diff -q big_b.bin bb_e/big_b.bin > /dev/null 2>&1 && echo 1 || echo 0)
DCHK "1MB roundtrip (random + structured)" "$A" "$B"
# B3. Truncated archive rejected (path A: cut last 50 bytes) (path B: cut at midpoint)
cp a.zupt tr1.zupt; cp a.zupt tr2.zupt
truncate -s -50 tr1.zupt
truncate -s 100 tr2.zupt
mkdir -p tr1e tr2e
(cd tr1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../tr1.zupt > /dev/null 2>&1)
(cd tr2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../tr2.zupt > /dev/null 2>&1)
A=$([ ! -f tr1e/input.txt ] && echo 1 || echo 0)
B=$([ ! -f tr2e/input.txt ] && echo 1 || echo 0)
DCHK "Truncated archive rejected" "$A" "$B"
echo " [C. Format compatibility]"
# C1. Mode confusion: SDK archive cannot be read with --pq (legacy)
mkdir -p mc1 && (cd mc1 && "$ZUPT_BIN" x --pq ../legacy.key ../a.zupt > /dev/null 2>&1)
A=$([ ! -f mc1/input.txt ] && echo 1 || echo 0)
# Also: legacy archive cannot be read with --pq-sdk
"$ZUPT_BIN" c --pq legacy.key leg.zupt input.txt > /dev/null 2>&1
mkdir -p mc2 && (cd mc2 && "$ZUPT_BIN" x --pq-sdk ../k.priv ../leg.zupt > /dev/null 2>&1)
B=$([ ! -f mc2/input.txt ] && echo 1 || echo 0)
DCHK "Mode confusion prevented (SDK↔legacy)" "$A" "$B"
# C2. Legacy archive readable with legacy key (compat baseline)
mkdir -p lc && (cd lc && "$ZUPT_BIN" x --pq ../legacy.key ../leg.zupt > /dev/null 2>&1)
A=$(diff -q lc/input.txt input.txt > /dev/null 2>&1 && echo 1 || echo 0)
# B: SDK archive readable with SDK key (compat baseline)
mkdir -p sc && (cd sc && "$ZUPT_BIN" x --pq-sdk ../k.priv ../a.zupt > /dev/null 2>&1)
B=$(diff -q sc/input.txt input.txt > /dev/null 2>&1 && echo 1 || echo 0)
DCHK "Both SDK and legacy paths roundtrip independently" "$A" "$B"
echo " [D. Robustness]"
# D1. Non-existent input handled
"$ZUPT_BIN" c --pq-sdk k.priv.pub nx.zupt /nonexistent_file_12345 > /dev/null 2>&1
A=$([ ! -f nx.zupt ] && echo 1 || echo 0)
"$ZUPT_BIN" c --pq-sdk k.priv.pub nx2.zupt /dev/nonexistent > /dev/null 2>&1
B=$([ ! -f nx2.zupt ] && echo 1 || echo 0)
DCHK "Missing input file rejected cleanly" "$A" "$B"
# D2. Non-existent key handled
mkdir -p nk1 && (cd nk1 && "$ZUPT_BIN" x --pq-sdk /nonexistent.key ../a.zupt > /dev/null 2>&1)
A=$([ ! -f nk1/input.txt ] && echo 1 || echo 0)
"$ZUPT_BIN" c --pq-sdk /nonexistent.pub bbnk.zupt input.txt > /dev/null 2>&1
B=$([ ! -s bbnk.zupt ] && echo 1 || echo 0)
DCHK "Missing key file rejected cleanly" "$A" "$B"
# D3. Multiple files in one archive
mkdir -p multi
echo "a" > multi/a.txt; echo "b" > multi/b.txt; echo "c" > multi/c.txt
"$ZUPT_BIN" c --pq-sdk k.priv.pub mm.zupt multi/a.txt multi/b.txt multi/c.txt > /dev/null 2>&1
mkdir -p mmex && (cd mmex && "$ZUPT_BIN" x --pq-sdk ../k.priv ../mm.zupt > /dev/null 2>&1)
A=$(diff -q mmex/multi/a.txt multi/a.txt > /dev/null 2>&1 && echo 1 || echo 0)
B=$([ -f mmex/multi/c.txt ] && [ "$(cat mmex/multi/c.txt 2>/dev/null)" = "c" ] && echo 1 || echo 0)
DCHK "Multiple files in archive roundtrip" "$A" "$B"
echo
echo " ───────────────────────────────────────"
echo " Audit results: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]

180
tests/test_block_swap.sh Executable file
View file

@ -0,0 +1,180 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Bug #16 regression — Block-swap attack on encrypted archives.
#
# Pre-fix vulnerability:
# 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. An attacker who swapped two valid encrypted
# blocks (header + payload) between positions could produce an archive
# that decrypts cleanly but extracts files with the wrong content.
#
# Fix:
# Bind block_seq into MAC as 8-byte LE AAD. Encrypt is now MAC-over
# (nonce || ciphertext || block_seq_LE). Decrypt tries v2 first, falls
# back to legacy v1 for old archives. Per-file block_seq is used so
# extract-side counter matches encrypt-side without needing extra
# index metadata.
#
# This test:
# 1. Creates an encrypted archive with two distinct files A and B
# 2. Performs the block-swap surgery on the binary
# 3. Verifies extract REJECTS the swapped archive (auth failure)
# 4. Also verifies normal extract still works (regression guard)
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
chk() {
if [ $? -eq 0 ]; then echo "$1"; PASS=$((PASS+1))
else echo "$1"; FAIL=$((FAIL+1)); fi
}
echo " [Bug #16 — Block-swap (reorder) attack defense]"
# Two distinct files, small enough that each fits in one block
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' > file_A.txt
printf 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n' > file_B.txt
# Build encrypted archive (small block to ensure 1 block per file)
"$ZUPT_BIN" c -p mypassword -b 64 -t 1 archive.zupt file_A.txt file_B.txt > /dev/null 2>&1
# P1: Normal extract still works (regression guard)
mkdir extract_normal
"$ZUPT_BIN" x archive.zupt -p mypassword -o extract_normal > /dev/null 2>&1
[ -f extract_normal/file_A.txt ] && [ -f extract_normal/file_B.txt ] && \
diff -q file_A.txt extract_normal/file_A.txt > /dev/null && \
diff -q file_B.txt extract_normal/file_B.txt > /dev/null
chk "Normal extract still works (regression guard)"
# P2: The block-swap attack must FAIL (no files extracted, or wrong files rejected)
python3 << 'PYEOF'
import sys, struct
data = bytearray(open('archive.zupt','rb').read())
# Find DATA blocks via block magic 0xbb 0x01 + block_type=DATA(0)
def parse_blocks(data):
blocks = []
i = 0
while i < len(data) - 7:
if data[i] == 0xbb and data[i+1] == 0x01:
block_type = data[i+2]
codec = struct.unpack('<H', bytes(data[i+3:i+5]))[0]
flags = struct.unpack('<H', bytes(data[i+5:i+7]))[0]
# parse varint uncomp
idx = i + 7
uncomp, shift = 0, 0
while idx < len(data):
b = data[idx]
uncomp |= (b & 0x7F) << shift
idx += 1
if not (b & 0x80): break
shift += 7
comp, shift = 0, 0
while idx < len(data):
b = data[idx]
comp |= (b & 0x7F) << shift
idx += 1
if not (b & 0x80): break
shift += 7
payload_start = idx + 8 # skip 8-byte checksum
block_end = payload_start + comp
blocks.append({
'type': block_type, 'flags': flags,
'start': i, 'end': block_end, 'comp': comp,
})
i = block_end
else:
i += 1
return blocks
blocks = parse_blocks(data)
data_blocks = [b for b in blocks if b['type'] == 0 and b['flags'] & 0x01]
if len(data_blocks) < 2:
print(f"Found only {len(data_blocks)} encrypted DATA blocks; can't swap", file=sys.stderr)
sys.exit(2)
# Swap the first two DATA blocks
B0 = bytes(data[data_blocks[0]['start']:data_blocks[0]['end']])
B1 = bytes(data[data_blocks[1]['start']:data_blocks[1]['end']])
swapped = bytearray(data)
# Swap (assume same size)
if len(B0) != len(B1):
print(f"different block sizes {len(B0)} vs {len(B1)}; can't swap directly", file=sys.stderr)
sys.exit(2)
swapped[data_blocks[0]['start']:data_blocks[0]['end']] = B1
swapped[data_blocks[1]['start']:data_blocks[1]['end']] = B0
open('archive_swapped.zupt','wb').write(bytes(swapped))
print(f"swap done", file=sys.stderr)
PYEOF
swap_status=$?
if [ $swap_status -eq 0 ]; then
mkdir extract_attack
out=$("$ZUPT_BIN" x archive_swapped.zupt -p mypassword -o extract_attack 2>&1)
rc=$?
# Attack defense check: at least one of these must hold
# - rc != 0 (extract returned error)
# - no files extracted
# - extracted files have wrong content (we reject this — would mean the
# bug is still present)
a_swapped=0; b_swapped=0
[ -f extract_attack/file_A.txt ] && cmp -s file_B.txt extract_attack/file_A.txt && a_swapped=1
[ -f extract_attack/file_B.txt ] && cmp -s file_A.txt extract_attack/file_B.txt && b_swapped=1
if [ "$a_swapped" = "1" ] && [ "$b_swapped" = "1" ]; then
# BAD: attack succeeded — file_A has B's content and vice versa
false
else
# GOOD: attack rejected (either error, no files, or files unchanged)
true
fi
chk "Block-swap attack rejected (cross-file reorder)"
else
echo " ⊘ Block-swap attack test skipped (couldn't locate block boundaries)"
fi
# P3: Single-block file (boundary case — empty seq_AAD doesn't degenerate)
echo "single block content" > tiny.txt
"$ZUPT_BIN" c -p mypassword tiny.zupt tiny.txt > /dev/null 2>&1
mkdir tiny_out
"$ZUPT_BIN" x tiny.zupt -p mypassword -o tiny_out > /dev/null 2>&1
[ -f tiny_out/tiny.txt ] && diff -q tiny.txt tiny_out/tiny.txt > /dev/null
chk "Single-block file roundtrip (boundary)"
# P4: Multi-block large file (ensures every block has correct AAD seq)
dd if=/dev/urandom of=big.bin bs=1024 count=512 2>/dev/null
"$ZUPT_BIN" c -p mypassword big.zupt big.bin > /dev/null 2>&1
mkdir big_out
"$ZUPT_BIN" x big.zupt -p mypassword -o big_out > /dev/null 2>&1
[ -f big_out/big.bin ] && diff -q big.bin big_out/big.bin > /dev/null
chk "512KB multi-block file roundtrip"
# P5: Multiple files in one archive (each gets per-file seq counter)
echo "first" > a.txt
echo "second" > b.txt
echo "third" > c.txt
"$ZUPT_BIN" c -p mypassword multi.zupt a.txt b.txt c.txt > /dev/null 2>&1
mkdir multi_out
"$ZUPT_BIN" x multi.zupt -p mypassword -o multi_out > /dev/null 2>&1
[ -f multi_out/a.txt ] && [ -f multi_out/b.txt ] && [ -f multi_out/c.txt ] && \
diff -q a.txt multi_out/a.txt > /dev/null && \
diff -q b.txt multi_out/b.txt > /dev/null && \
diff -q c.txt multi_out/c.txt > /dev/null
chk "Multi-file archive roundtrip (per-file seq counters)"
# P6: Wrong password still fails cleanly
mkdir wrong_pw
out=$("$ZUPT_BIN" x archive.zupt -p WRONG_PASSWORD -o wrong_pw 2>&1)
[ ! -f wrong_pw/file_A.txt ]
chk "Wrong password rejected"
echo
echo " ───────────────────────────────────────"
echo " Block-swap regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]

130
tests/test_dedup_props.sh Executable file
View file

@ -0,0 +1,130 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Property-based test for zupt dedup path.
# Generates random file sets with intentional duplicates, verifies that
# (a) compressed output is correct (byte-exact roundtrip) and
# (b) dedup actually saves space when duplicates are present.
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
chk() {
if [ $? -eq 0 ]; then echo "$1"; PASS=$((PASS+1))
else echo "$1"; FAIL=$((FAIL+1)); fi
}
# ─── Property 1: dedup roundtrip is byte-exact ──────────────────────────
# Generate 10 random base files + 5 exact duplicates; compress with --dedup;
# extract; verify every file matches its original.
echo " [P1. Dedup roundtrip preserves all bytes]"
mkdir input
for i in $(seq 1 10); do
dd if=/dev/urandom of=input/file_$i.bin bs=4K count=$((RANDOM % 8 + 1)) 2>/dev/null
done
# 5 exact duplicates (same content as file_1..5)
for i in 1 2 3 4 5; do
cp input/file_$i.bin input/dup_$i.bin
done
"$ZUPT_BIN" c --dedup test_dedup.zupt input/*.bin > /dev/null 2>&1
chk "Compress with --dedup succeeds"
mkdir extracted && cd extracted
"$ZUPT_BIN" x ../test_dedup.zupt > /dev/null 2>&1
chk "Extract --dedup archive succeeds"
all_match=1
for i in $(seq 1 10); do
if ! diff -q ../input/file_$i.bin tmp*/input/file_$i.bin > /dev/null 2>&1 \
&& ! diff -q ../input/file_$i.bin input/file_$i.bin > /dev/null 2>&1; then
all_match=0; break
fi
done
[ $all_match -eq 1 ]
chk "All 10 base files roundtrip byte-exact"
dup_match=1
for i in 1 2 3 4 5; do
found=0
for d in tmp*/input input; do
if [ -f "$d/dup_$i.bin" ] && diff -q ../input/dup_$i.bin "$d/dup_$i.bin" > /dev/null 2>&1; then
found=1; break
fi
done
[ $found -eq 1 ] || { dup_match=0; break; }
done
[ $dup_match -eq 1 ]
chk "All 5 duplicate files roundtrip byte-exact"
cd ..
# ─── Property 2: dedup reduces size for duplicate-heavy workloads ───────
echo " [P2. Dedup compresses better than non-dedup on duplicate-heavy data]"
mkdir dups
for i in $(seq 1 20); do
cp input/file_1.bin dups/copy_$i.bin
done
"$ZUPT_BIN" c no_dedup.zupt dups/*.bin > /dev/null 2>&1
"$ZUPT_BIN" c --dedup with_dedup.zupt dups/*.bin > /dev/null 2>&1
size_no=$(stat -c%s no_dedup.zupt 2>/dev/null || stat -f%z no_dedup.zupt)
size_yes=$(stat -c%s with_dedup.zupt 2>/dev/null || stat -f%z with_dedup.zupt)
[ "$size_yes" -lt "$size_no" ]
chk "Dedup archive ($size_yes B) smaller than non-dedup ($size_no B)"
ratio=$(awk "BEGIN{printf \"%.0f\", $size_yes * 100 / $size_no}")
[ "$ratio" -lt 50 ]
chk "Dedup achieves >50% reduction (got $ratio% of original)"
# ─── Property 3: dedup roundtrip preserves data on duplicate-only sets ──
echo " [P3. 100% duplicate file set extracts correctly]"
mkdir extr_dups && cd extr_dups
"$ZUPT_BIN" x ../with_dedup.zupt > /dev/null 2>&1
chk "Extract heavy-duplicate archive succeeds"
n_extracted=$(find . -name "copy_*.bin" 2>/dev/null | wc -l)
[ "$n_extracted" -eq 20 ]
chk "All 20 duplicate copies extracted (got $n_extracted)"
all_dup_match=1
for f in $(find . -name "copy_*.bin"); do
if ! diff -q "$f" ../input/file_1.bin > /dev/null 2>&1; then
all_dup_match=0; break
fi
done
[ $all_dup_match -eq 1 ]
chk "All extracted duplicates byte-exact match the original"
cd ..
# ─── Property 4: dedup + encryption coexist correctly ───────────────────
echo " [P4. Dedup + SDK encryption work together]"
"$ZUPT_BIN" keygen --sdk -o k.priv > /dev/null 2>&1
"$ZUPT_BIN" c --dedup --pq-sdk k.priv.pub enc_dedup.zupt dups/*.bin > /dev/null 2>&1
chk "Encrypt + dedup compress succeeds"
mkdir extr_enc && cd extr_enc
"$ZUPT_BIN" x --pq-sdk ../k.priv ../enc_dedup.zupt > /dev/null 2>&1
chk "Encrypt + dedup extract succeeds"
n=$(find . -name "copy_*.bin" 2>/dev/null | wc -l)
[ "$n" -eq 20 ]
chk "All 20 copies recovered after enc+dedup ($n found)"
cd ..
echo
echo " ───────────────────────────────────────"
echo " Dedup property results: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]

156
tests/test_path_traversal.sh Executable file
View file

@ -0,0 +1,156 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Path traversal / Zip Slip regression tests.
#
# Verifies that the v2.2.2 audit fixes for CVE-pattern path traversal
# (Snyk Zip Slip 2018) and symlink-following on extract are working.
#
# Tests construct malicious archives in two ways:
# (A) compress with a relative path then post-mutate the index (manual fuzz)
# (B) try to extract into a directory containing a symlink with the same
# name as an archive entry — should be refused due to O_NOFOLLOW.
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
chk() {
if [ $? -eq 0 ]; then echo "$1"; PASS=$((PASS+1))
else echo "$1"; FAIL=$((FAIL+1)); fi
}
# ─── Property 1: archive with ".." entry must not extract above target ──
# Strategy: compress an innocent file, then patch the archive's index to
# replace the path with "../../escaped.txt". Extract into a subdir;
# verify the file appears nowhere outside the subdir.
echo " [P1. Zip Slip — relative path traversal blocked]"
mkdir input output_safe
echo "secret content" > input/innocent.txt
"$ZUPT_BIN" c slip.zupt input/innocent.txt > /dev/null 2>&1
# Patch the archive: replace "input/innocent.txt" path string with
# "../escape.txt" in the index. We use a python helper because the index
# is varint-prefixed and we need to keep length consistent.
python3 << 'PYEOF'
import sys
data = bytearray(open('slip.zupt','rb').read())
target = b'input/innocent.txt'
replacement = b'../escape.txt'
# Pad replacement to same length so varint length prefix stays valid
pad = b'\x00' * (len(target) - len(replacement))
i = data.find(target)
if i < 0:
print("ERROR: pattern not in archive")
sys.exit(1)
# Replace the bytes — note this will fail validation below, which is OK,
# we want to see if extract REJECTS the malformed path.
data[i:i+len(target)] = replacement + pad
open('slip_patched.zupt','wb').write(bytes(data))
PYEOF
# Try to extract — even if the patched archive is corrupt, we want to
# verify that NO file appears at "../escape.txt" relative to output_safe.
cd output_safe
"$ZUPT_BIN" x ../slip_patched.zupt > /dev/null 2>&1
cd ..
# The key invariant: nothing escaped to TMPDIR (parent of output_safe)
[ ! -f "$TMPDIR/escape.txt" ] && [ ! -f escape.txt ]
chk "No escape via patched ../escape.txt path"
# ─── Property 2: archive with absolute path must not write to that path ──
echo " [P2. Absolute path entries blocked]"
# Construct an archive entry with absolute "/tmp/owned.txt" via patching
echo "innocent" > input2.txt
"$ZUPT_BIN" c abs.zupt input2.txt > /dev/null 2>&1
python3 << 'PYEOF'
data = bytearray(open('abs.zupt','rb').read())
target = b'input2.txt'
# Replace with absolute path of equal length
replacement = b'/tmp/owned' # 10 chars vs 10 chars
i = data.find(target)
if i >= 0:
data[i:i+len(target)] = replacement
open('abs_patched.zupt','wb').write(bytes(data))
PYEOF
mkdir abs_extract
cd abs_extract
"$ZUPT_BIN" x ../abs_patched.zupt > /dev/null 2>&1
cd ..
[ ! -f /tmp/owned ]
chk "Absolute /tmp/owned path rejected"
# ─── Property 3: symlink at output target is not followed ──────────────
# Pre-place a symlink in output dir pointing to a sentinel file.
# Extract an archive with the same entry name; verify the sentinel is
# unchanged (i.e. extract refused to follow the symlink).
echo " [P3. Symlink at extract target not followed]"
echo "DO_NOT_OVERWRITE" > sentinel.txt
mkdir symlink_extract
ln -s "$(pwd)/sentinel.txt" symlink_extract/innocent.txt
# Build a fresh non-patched archive with "innocent.txt"
mkdir input3 && echo "evil overwrite content" > input3/innocent.txt
"$ZUPT_BIN" c clean.zupt input3/innocent.txt > /dev/null 2>&1
# Mutate path "input3/innocent.txt" -> "innocent.txt" so it lands at the symlink
python3 << 'PYEOF'
data = bytearray(open('clean.zupt','rb').read())
target = b'input3/innocent.txt'
replacement = b'innocent.txt' + (b'\x00' * (len(target) - len(b'innocent.txt')))
i = data.find(target)
if i >= 0:
data[i:i+len(target)] = replacement
open('clean_patched.zupt','wb').write(bytes(data))
PYEOF
cd symlink_extract
"$ZUPT_BIN" x ../clean_patched.zupt > /dev/null 2>&1
cd ..
# Sentinel must be unchanged — symlink follow would have overwritten it
content=$(cat sentinel.txt)
[ "$content" = "DO_NOT_OVERWRITE" ]
chk "Sentinel via symlink not overwritten"
# ─── Property 4: legitimate paths still extract correctly ─────────────
echo " [P4. Legitimate (safe) paths still extract]"
mkdir legit_input
echo "ok content" > legit_input/normal.txt
"$ZUPT_BIN" c legit.zupt legit_input/normal.txt > /dev/null 2>&1
mkdir legit_extract && cd legit_extract
"$ZUPT_BIN" x ../legit.zupt > /dev/null 2>&1
cd ..
[ -f legit_extract/legit_input/normal.txt ] && \
[ "$(cat legit_extract/legit_input/normal.txt)" = "ok content" ]
chk "Normal extraction still works"
# ─── Property 5: deep path (allowed) but parent dir is created ─────────
echo " [P5. Multi-component safe paths still work]"
mkdir deep && mkdir deep/sub && mkdir deep/sub/sub2
echo "deep" > deep/sub/sub2/file.txt
"$ZUPT_BIN" c deep.zupt deep/sub/sub2/file.txt > /dev/null 2>&1
mkdir deep_extract && cd deep_extract
"$ZUPT_BIN" x ../deep.zupt > /dev/null 2>&1
cd ..
[ -f deep_extract/deep/sub/sub2/file.txt ]
chk "Deep nested path extracted"
echo
echo " ───────────────────────────────────────"
echo " Path-traversal regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]

View file

@ -1,4 +1,6 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
set +e
ZUPT="${1:-./zupt}"
T="/tmp/zupt_pq_$$"; mkdir -p "$T/data"

72
tests/test_sdk.sh Executable file
View file

@ -0,0 +1,72 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Test zupt SDK-backed PQ encryption (v2.2+)
cd "$(dirname "$0")/.."
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
chk() { if [ $? -eq 0 ]; then echo " OK: $1"; PASS=$((PASS+1)); else echo " FAIL: $1"; FAIL=$((FAIL+1)); fi; }
chk_neg() { if [ $? -ne 0 ]; then echo " OK: $1"; PASS=$((PASS+1)); else echo " FAIL: $1 (should have failed)"; FAIL=$((FAIL+1)); fi; }
# Setup: real keypair
"$ZUPT_BIN" keygen --sdk -o key.priv > /dev/null 2>&1
[ -f key.priv ] && [ -f key.priv.pub ]
chk "SDK keygen produces both files"
# Test data
echo "Hello SDK PQ encryption" > input.txt
dd if=/dev/urandom of=large.bin bs=64K count=4 2>/dev/null
# Roundtrip small file
"$ZUPT_BIN" c --pq-sdk key.priv.pub small.zupt input.txt > /dev/null 2>&1
chk "SDK encrypt small"
mkdir -p extract1 && cd extract1
"$ZUPT_BIN" x --pq-sdk ../key.priv ../small.zupt > /dev/null 2>&1
chk "SDK decrypt small"
diff -q input.txt ../input.txt > /dev/null 2>&1
chk "SDK small roundtrip byte-exact"
cd ..
# Roundtrip large file
"$ZUPT_BIN" c --pq-sdk key.priv.pub large.zupt large.bin > /dev/null 2>&1
chk "SDK encrypt large (256KB)"
mkdir -p extract2 && cd extract2
"$ZUPT_BIN" x --pq-sdk ../key.priv ../large.zupt > /dev/null 2>&1
chk "SDK decrypt large"
diff -q large.bin ../large.bin > /dev/null 2>&1
chk "SDK large roundtrip byte-exact"
cd ..
# Wrong key rejected
"$ZUPT_BIN" keygen --sdk -o other.priv > /dev/null 2>&1
"$ZUPT_BIN" x --pq-sdk other.priv small.zupt > /dev/null 2>&1
chk_neg "SDK wrong key rejected"
# Tamper detected
cp small.zupt tampered.zupt
python3 -c "
b = bytearray(open('tampered.zupt','rb').read())
b[len(b)-50] ^= 1
open('tampered.zupt','wb').write(bytes(b))
"
"$ZUPT_BIN" x --pq-sdk key.priv tampered.zupt > /dev/null 2>&1
chk_neg "SDK tampered ciphertext rejected"
# Legacy v1 compat: legacy --pq still works
"$ZUPT_BIN" keygen -o legacy.key > /dev/null 2>&1
"$ZUPT_BIN" c --pq legacy.key legacy.zupt input.txt > /dev/null 2>&1
chk "Legacy --pq still encrypts"
mkdir -p extract3 && cd extract3
"$ZUPT_BIN" x --pq ../legacy.key ../legacy.zupt > /dev/null 2>&1
chk "Legacy --pq still decrypts"
cd ..
echo
echo " Results: $PASS passed, $FAIL failed ($((PASS+FAIL)) tests)"
[ $FAIL -eq 0 ]

View file

@ -1,4 +1,6 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
set +e
ZUPT="./zupt"
T="/tmp/zupt_mt_$$"

View file

@ -1,7 +1,8 @@
/*
* Zupt NIST/RFC Cryptographic Test Vectors
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later (commercial: sac@securityops.co)
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* Zupt NIST/RFC Cryptographic Test Vectors
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Tests: SHA-256 (FIPS 180-4), HMAC-SHA256 (RFC 4231),
* X25519 (RFC 7748 §6.1), ML-KEM-768 roundtrip,