release: restore ZUPT and harden source-only 5.2.2
This commit is contained in:
parent
74e393ba3e
commit
ff99770bd0
205 changed files with 19627 additions and 13215 deletions
371
tests/test_key_files.sh
Normal file
371
tests/test_key_files.sh
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Copyright (c) 2026 Cristian Cezar Moises
|
||||
#
|
||||
# Key-file security regression coverage for the native ZKEY and ZPQK formats.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
|
||||
zupt_bin=${1:-$repo_root/zupt}
|
||||
if [[ $zupt_bin != /* ]]; then
|
||||
zupt_bin=$(CDPATH='' cd -- "$(dirname -- "$zupt_bin")" 2>/dev/null && pwd -P)/$(basename -- "$zupt_bin")
|
||||
fi
|
||||
if [[ ! -x $zupt_bin ]]; then
|
||||
printf 'FAIL: executable not found: %s\n' "$zupt_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-key-files.XXXXXXXX") || exit 1
|
||||
trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT HUP INT TERM
|
||||
|
||||
passes=0
|
||||
failures=0
|
||||
case_number=0
|
||||
|
||||
pass() {
|
||||
passes=$((passes + 1))
|
||||
printf ' PASS: %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
failures=$((failures + 1))
|
||||
printf ' FAIL: %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
file_mode() {
|
||||
if stat -c '%a' "$1" >/dev/null 2>&1; then
|
||||
stat -c '%a' "$1"
|
||||
else
|
||||
stat -f '%Lp' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
generate_with_mode() {
|
||||
local label=$1 mask=$2 output=$3
|
||||
shift 3
|
||||
if (umask "$mask"; "$zupt_bin" keygen "$@" -o "$output" >/dev/null 2>&1); then
|
||||
local mode
|
||||
mode=$(file_mode "$output")
|
||||
if [[ $mode == 600 ]]; then
|
||||
pass "$label is mode 0600 under umask $mask"
|
||||
else
|
||||
fail "$label mode under umask $mask is $mode, expected 600"
|
||||
fi
|
||||
else
|
||||
fail "$label generation failed under umask $mask"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_generation_refused() {
|
||||
local label=$1 output=$2 expected=$3
|
||||
shift 3
|
||||
if "$zupt_bin" keygen "$@" -o "$output" >/dev/null 2>&1; then
|
||||
fail "$label unexpectedly replaced an existing destination"
|
||||
elif [[ -f $output && ! -L $output && $(<"$output") == "$expected" ]]; then
|
||||
pass "$label refuses an existing file without modifying it"
|
||||
else
|
||||
fail "$label changed or removed an existing file"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_symlink_refused() {
|
||||
local label=$1 link=$2 target=$3 expected=$4
|
||||
shift 4
|
||||
if "$zupt_bin" keygen "$@" -o "$link" >/dev/null 2>&1; then
|
||||
fail "$label unexpectedly followed an output symlink"
|
||||
elif [[ -L $link && -f $target && $(<"$target") == "$expected" ]]; then
|
||||
pass "$label refuses a symlink without modifying its target"
|
||||
else
|
||||
fail "$label changed the symlink or its target"
|
||||
fi
|
||||
}
|
||||
|
||||
# Mutate a valid native key. Header-only mutations receive a newly calculated
|
||||
# XXH64 so they prove the parser checks magic/version/flags/reserved/role rather
|
||||
# than merely reaching the checksum rejection. XXH64 remains a corruption check,
|
||||
# not authentication of an intentionally substituted public key.
|
||||
mutate_key() {
|
||||
python3 - "$1" "$2" "$3" <<'PY'
|
||||
import struct
|
||||
import sys
|
||||
|
||||
MASK = (1 << 64) - 1
|
||||
P1 = 11400714785074694791
|
||||
P2 = 14029467366897019727
|
||||
P3 = 1609587929392839161
|
||||
P4 = 9650029242287828579
|
||||
P5 = 2870177450012600261
|
||||
|
||||
def rol(value, bits):
|
||||
return ((value << bits) | (value >> (64 - bits))) & MASK
|
||||
|
||||
def round64(acc, value):
|
||||
acc = (acc + value * P2) & MASK
|
||||
acc = rol(acc, 31)
|
||||
return (acc * P1) & MASK
|
||||
|
||||
def merge_round(acc, value):
|
||||
acc ^= round64(0, value)
|
||||
return (acc * P1 + P4) & MASK
|
||||
|
||||
def xxh64(data, seed=0):
|
||||
length = len(data)
|
||||
pos = 0
|
||||
if length >= 32:
|
||||
v1 = (seed + P1 + P2) & MASK
|
||||
v2 = (seed + P2) & MASK
|
||||
v3 = seed & MASK
|
||||
v4 = (seed - P1) & MASK
|
||||
limit = length - 32
|
||||
while pos <= limit:
|
||||
v1 = round64(v1, struct.unpack_from('<Q', data, pos)[0]); pos += 8
|
||||
v2 = round64(v2, struct.unpack_from('<Q', data, pos)[0]); pos += 8
|
||||
v3 = round64(v3, struct.unpack_from('<Q', data, pos)[0]); pos += 8
|
||||
v4 = round64(v4, struct.unpack_from('<Q', data, pos)[0]); pos += 8
|
||||
result = rol(v1, 1) + rol(v2, 7) + rol(v3, 12) + rol(v4, 18)
|
||||
result &= MASK
|
||||
result = merge_round(result, v1)
|
||||
result = merge_round(result, v2)
|
||||
result = merge_round(result, v3)
|
||||
result = merge_round(result, v4)
|
||||
else:
|
||||
result = (seed + P5) & MASK
|
||||
|
||||
result = (result + length) & MASK
|
||||
while pos + 8 <= length:
|
||||
lane = round64(0, struct.unpack_from('<Q', data, pos)[0])
|
||||
result ^= lane
|
||||
result = (rol(result, 27) * P1 + P4) & MASK
|
||||
pos += 8
|
||||
if pos + 4 <= length:
|
||||
result ^= (struct.unpack_from('<I', data, pos)[0] * P1) & MASK
|
||||
result = (rol(result, 23) * P2 + P3) & MASK
|
||||
pos += 4
|
||||
while pos < length:
|
||||
result ^= (data[pos] * P5) & MASK
|
||||
result = (rol(result, 11) * P1) & MASK
|
||||
pos += 1
|
||||
result ^= result >> 33
|
||||
result = (result * P2) & MASK
|
||||
result ^= result >> 29
|
||||
result = (result * P3) & MASK
|
||||
result ^= result >> 32
|
||||
return result & MASK
|
||||
|
||||
source, destination, mutation = sys.argv[1:]
|
||||
data = bytearray(open(source, 'rb').read())
|
||||
if len(data) < 16:
|
||||
raise SystemExit('source key is unexpectedly short')
|
||||
stored = int.from_bytes(data[-8:], 'little')
|
||||
if stored != xxh64(data[:-8]):
|
||||
raise SystemExit('source key checksum does not match the format')
|
||||
|
||||
recheck = False
|
||||
if mutation == 'magic':
|
||||
data[0] ^= 0x20
|
||||
recheck = True
|
||||
elif mutation == 'version':
|
||||
data[4] = 2
|
||||
recheck = True
|
||||
elif mutation == 'flag':
|
||||
data[5] = 0x80
|
||||
recheck = True
|
||||
elif mutation == 'reserved':
|
||||
data[6] = 1
|
||||
recheck = True
|
||||
elif mutation == 'role':
|
||||
data[5] ^= 1
|
||||
recheck = True
|
||||
elif mutation == 'key':
|
||||
data[16] ^= 1
|
||||
elif mutation == 'secret':
|
||||
if data[5] != 1:
|
||||
raise SystemExit('secret mutation requires a private key')
|
||||
data[-16] ^= 1
|
||||
elif mutation == 'checksum':
|
||||
data[-1] ^= 1
|
||||
elif mutation == 'truncated':
|
||||
del data[-1]
|
||||
elif mutation == 'appended':
|
||||
data.append(0x41)
|
||||
else:
|
||||
raise SystemExit('unknown mutation: ' + mutation)
|
||||
|
||||
if recheck:
|
||||
data[-8:] = xxh64(data[:-8]).to_bytes(8, 'little')
|
||||
open(destination, 'wb').write(data)
|
||||
PY
|
||||
}
|
||||
|
||||
expect_public_rejected() {
|
||||
local format=$1 option=$2 key=$3 label=$4
|
||||
case_number=$((case_number + 1))
|
||||
local archive=$test_root/rejected-public-$case_number.zupt
|
||||
if "$zupt_bin" compress "$option" "$key" "$archive" \
|
||||
"$test_root/input.txt" >/dev/null 2>&1; then
|
||||
fail "$format public key accepts $label"
|
||||
elif [[ -e $archive ]]; then
|
||||
fail "$format public key rejection published an archive for $label"
|
||||
else
|
||||
pass "$format public key rejects $label"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_private_rejected() {
|
||||
local format=$1 key=$2 label=$3
|
||||
case_number=$((case_number + 1))
|
||||
local public=$test_root/rejected-private-$case_number.pub
|
||||
if [[ $format == ZKEY ]]; then
|
||||
if "$zupt_bin" keygen --pub -o "$public" -k "$key" >/dev/null 2>&1; then
|
||||
fail "$format private key accepts $label"
|
||||
return
|
||||
fi
|
||||
else
|
||||
if "$zupt_bin" keygen --pub --pq-only -o "$public" -k "$key" \
|
||||
>/dev/null 2>&1; then
|
||||
fail "$format private key accepts $label"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
if [[ -e $public ]]; then
|
||||
fail "$format private key rejection published output for $label"
|
||||
else
|
||||
pass "$format private key rejects $label"
|
||||
fi
|
||||
}
|
||||
|
||||
printf 'key-file security regression input\n' >"$test_root/input.txt"
|
||||
|
||||
printf 'Key-file permissions and no-replace publication\n'
|
||||
generate_with_mode 'ZKEY private key' 022 "$test_root/hybrid-022.key"
|
||||
generate_with_mode 'ZKEY private key' 000 "$test_root/hybrid-000.key"
|
||||
generate_with_mode 'ZPQK private key' 022 "$test_root/pq-022.key" --pq-only
|
||||
generate_with_mode 'ZPQK private key' 000 "$test_root/pq-000.key" --pq-only
|
||||
|
||||
printf 'hybrid sentinel' >"$test_root/existing-hybrid.key"
|
||||
expect_generation_refused 'ZKEY generation' "$test_root/existing-hybrid.key" \
|
||||
'hybrid sentinel'
|
||||
printf 'pq sentinel' >"$test_root/existing-pq.key"
|
||||
expect_generation_refused 'ZPQK generation' "$test_root/existing-pq.key" \
|
||||
'pq sentinel' --pq-only
|
||||
|
||||
if ln -s "$test_root/hybrid-target" "$test_root/hybrid-link" 2>/dev/null; then
|
||||
printf 'hybrid target sentinel' >"$test_root/hybrid-target"
|
||||
expect_symlink_refused 'ZKEY generation' "$test_root/hybrid-link" \
|
||||
"$test_root/hybrid-target" 'hybrid target sentinel'
|
||||
else
|
||||
printf ' SKIP: symlinks unavailable for ZKEY no-follow test\n'
|
||||
fi
|
||||
if ln -s "$test_root/pq-target" "$test_root/pq-link" 2>/dev/null; then
|
||||
printf 'pq target sentinel' >"$test_root/pq-target"
|
||||
expect_symlink_refused 'ZPQK generation' "$test_root/pq-link" \
|
||||
"$test_root/pq-target" 'pq target sentinel' --pq-only
|
||||
else
|
||||
printf ' SKIP: symlinks unavailable for ZPQK no-follow test\n'
|
||||
fi
|
||||
|
||||
cp "$test_root/hybrid-022.key" "$test_root/hybrid-before-same-path.key"
|
||||
if "$zupt_bin" keygen --pub -o "$test_root/hybrid-022.key" \
|
||||
-k "$test_root/hybrid-022.key" >/dev/null 2>&1; then
|
||||
fail 'ZKEY public export accepted the private input as its output path'
|
||||
elif cmp -s "$test_root/hybrid-before-same-path.key" \
|
||||
"$test_root/hybrid-022.key"; then
|
||||
pass 'ZKEY same-path public export preserves the private key'
|
||||
else
|
||||
fail 'ZKEY same-path public export modified the private key'
|
||||
fi
|
||||
|
||||
cp "$test_root/pq-022.key" "$test_root/pq-before-same-path.key"
|
||||
if "$zupt_bin" keygen --pub --pq-only -o "$test_root/pq-022.key" \
|
||||
-k "$test_root/pq-022.key" >/dev/null 2>&1; then
|
||||
fail 'ZPQK public export accepted the private input as its output path'
|
||||
elif cmp -s "$test_root/pq-before-same-path.key" "$test_root/pq-022.key"; then
|
||||
pass 'ZPQK same-path public export preserves the private key'
|
||||
else
|
||||
fail 'ZPQK same-path public export modified the private key'
|
||||
fi
|
||||
|
||||
printf '\nValid key workflows\n'
|
||||
if "$zupt_bin" keygen --pub -o "$test_root/hybrid.pub" \
|
||||
-k "$test_root/hybrid-022.key" >/dev/null 2>&1 &&
|
||||
"$zupt_bin" compress --pq "$test_root/hybrid.pub" \
|
||||
"$test_root/hybrid.zupt" "$test_root/input.txt" >/dev/null 2>&1 &&
|
||||
"$zupt_bin" extract --pq "$test_root/hybrid-022.key" \
|
||||
-o "$test_root/hybrid-out" "$test_root/hybrid.zupt" >/dev/null 2>&1 &&
|
||||
hybrid_extracted=$(find "$test_root/hybrid-out" -name input.txt -type f \
|
||||
-print -quit) && [[ -n $hybrid_extracted ]] &&
|
||||
cmp -s "$test_root/input.txt" "$hybrid_extracted"; then
|
||||
pass 'valid ZKEY public/private round trip'
|
||||
else
|
||||
fail 'valid ZKEY public/private round trip'
|
||||
fi
|
||||
|
||||
if "$zupt_bin" keygen --pub --pq-only -o "$test_root/pq.pub" \
|
||||
-k "$test_root/pq-022.key" >/dev/null 2>&1 &&
|
||||
"$zupt_bin" compress --pq-only "$test_root/pq.pub" \
|
||||
"$test_root/pq.zupt" "$test_root/input.txt" >/dev/null 2>&1 &&
|
||||
"$zupt_bin" extract --pq-only "$test_root/pq-022.key" \
|
||||
-o "$test_root/pq-out" "$test_root/pq.zupt" >/dev/null 2>&1 &&
|
||||
pq_extracted=$(find "$test_root/pq-out" -name input.txt -type f \
|
||||
-print -quit) && [[ -n $pq_extracted ]] &&
|
||||
cmp -s "$test_root/input.txt" "$pq_extracted"; then
|
||||
pass 'valid ZPQK public/private round trip'
|
||||
else
|
||||
fail 'valid ZPQK public/private round trip'
|
||||
fi
|
||||
|
||||
# Compatibility: native readers historically allowed the private file itself
|
||||
# wherever a public recipient key was accepted.
|
||||
if "$zupt_bin" compress --pq "$test_root/hybrid-022.key" \
|
||||
"$test_root/hybrid-private-recipient.zupt" "$test_root/input.txt" \
|
||||
>/dev/null 2>&1; then
|
||||
pass 'valid private ZKEY remains accepted as recipient input'
|
||||
else
|
||||
fail 'valid private ZKEY recipient compatibility'
|
||||
fi
|
||||
if "$zupt_bin" compress --pq-only "$test_root/pq-022.key" \
|
||||
"$test_root/pq-private-recipient.zupt" "$test_root/input.txt" \
|
||||
>/dev/null 2>&1; then
|
||||
pass 'valid private ZPQK remains accepted as recipient input'
|
||||
else
|
||||
fail 'valid private ZPQK recipient compatibility'
|
||||
fi
|
||||
|
||||
printf '\nMalformed native key rejection\n'
|
||||
metadata_mutations=(magic version flag reserved role key checksum truncated appended)
|
||||
for mutation in "${metadata_mutations[@]}"; do
|
||||
hybrid_bad=$test_root/hybrid-public-$mutation.key
|
||||
if mutate_key "$test_root/hybrid.pub" "$hybrid_bad" "$mutation"; then
|
||||
expect_public_rejected ZKEY --pq "$hybrid_bad" "$mutation"
|
||||
else
|
||||
fail "could not create ZKEY public mutation: $mutation"
|
||||
fi
|
||||
|
||||
pq_bad=$test_root/pq-public-$mutation.key
|
||||
if mutate_key "$test_root/pq.pub" "$pq_bad" "$mutation"; then
|
||||
expect_public_rejected ZPQK --pq-only "$pq_bad" "$mutation"
|
||||
else
|
||||
fail "could not create ZPQK public mutation: $mutation"
|
||||
fi
|
||||
done
|
||||
|
||||
private_mutations=(magic version flag reserved role key secret checksum truncated appended)
|
||||
for mutation in "${private_mutations[@]}"; do
|
||||
hybrid_bad=$test_root/hybrid-private-$mutation.key
|
||||
if mutate_key "$test_root/hybrid-022.key" "$hybrid_bad" "$mutation"; then
|
||||
expect_private_rejected ZKEY "$hybrid_bad" "$mutation"
|
||||
else
|
||||
fail "could not create ZKEY private mutation: $mutation"
|
||||
fi
|
||||
|
||||
pq_bad=$test_root/pq-private-$mutation.key
|
||||
if mutate_key "$test_root/pq-022.key" "$pq_bad" "$mutation"; then
|
||||
expect_private_rejected ZPQK "$pq_bad" "$mutation"
|
||||
else
|
||||
fail "could not create ZPQK private mutation: $mutation"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\nKey-file results: %d passed, %d failed\n' "$passes" "$failures"
|
||||
((failures == 0))
|
||||
Loading…
Reference in a new issue