feat(05-01): add busybox-compatible shell decoder script

- POSIX sh script with full decode pipeline: header parse -> TOC parse -> HMAC verify -> decrypt -> decompress -> SHA-256 verify -> write
- Hardcoded KEY_HEX matching src/key.rs
- xxd/od fallback detection for hex conversion
- Graceful HMAC degradation if openssl lacks -mac support
- UTF-8 filename preservation for Cyrillic names
- Verified byte-identical extraction against Rust archiver output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
NikitolProject
2026-02-25 01:40:21 +03:00
parent 7331f4c0bb
commit 6df2639a45

250
shell/decode.sh Executable file
View File

@@ -0,0 +1,250 @@
#!/bin/sh
# decode.sh -- Busybox-compatible archive decoder
# Extracts files from archives created by the Rust archiver (encrypted_archive).
# Uses only: dd, xxd/od, openssl, gunzip, sha256sum
#
# Usage: decode.sh <archive_file> <output_dir>
set -e
export LC_ALL=C
# -------------------------------------------------------
# Usage check
# -------------------------------------------------------
if [ $# -ne 2 ]; then
printf 'Usage: %s <archive_file> <output_dir>\n' "$0" >&2
exit 1
fi
ARCHIVE="$1"
OUTPUT_DIR="$2"
if [ ! -f "$ARCHIVE" ]; then
printf 'Error: archive file not found: %s\n' "$ARCHIVE" >&2
exit 1
fi
mkdir -p "$OUTPUT_DIR"
# -------------------------------------------------------
# Hardcoded 32-byte AES-256 key (matches src/key.rs)
# -------------------------------------------------------
KEY_HEX="7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550"
# -------------------------------------------------------
# Prerequisite checks
# -------------------------------------------------------
for tool in dd openssl sha256sum; do
if ! command -v "$tool" >/dev/null 2>&1; then
printf 'Error: required tool not found: %s\n' "$tool" >&2
exit 1
fi
done
# -------------------------------------------------------
# Detect xxd vs od fallback
# -------------------------------------------------------
if command -v xxd >/dev/null 2>&1; then
HAS_XXD=1
else
HAS_XXD=0
fi
# -------------------------------------------------------
# Temporary directory with cleanup trap
# -------------------------------------------------------
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# -------------------------------------------------------
# Hex reading functions
# -------------------------------------------------------
# read_hex <file> <offset> <count>
# Returns lowercase hex string of <count> bytes at <offset>
read_hex() {
if [ "$HAS_XXD" = "1" ]; then
dd if="$1" bs=1 skip="$2" count="$3" 2>/dev/null | xxd -p | tr -d '\n'
else
dd if="$1" bs=1 skip="$2" count="$3" 2>/dev/null | od -A n -t x1 | tr -d ' \n'
fi
}
# read_le_u16 <file> <offset>
# Reads 2 bytes little-endian, prints decimal
read_le_u16() {
_hex=$(read_hex "$1" "$2" 2)
_b0=$(printf '%.2s' "$_hex")
_b1=$(printf '%.2s' "${_hex#??}")
printf '%d' "0x${_b1}${_b0}"
}
# read_le_u32 <file> <offset>
# Reads 4 bytes little-endian, prints decimal
read_le_u32() {
_hex=$(read_hex "$1" "$2" 4)
_b0=$(printf '%.2s' "$_hex")
_b1=$(printf '%.2s' "${_hex#??}")
_rest="${_hex#????}"
_b2=$(printf '%.2s' "$_rest")
_b3=$(printf '%.2s' "${_rest#??}")
printf '%d' "0x${_b3}${_b2}${_b1}${_b0}"
}
# -------------------------------------------------------
# HMAC availability detection
# -------------------------------------------------------
SKIP_HMAC=0
if ! printf 'test' | openssl dgst -sha256 -mac HMAC -macopt hexkey:00 >/dev/null 2>&1; then
printf 'WARNING: openssl HMAC not supported, skipping HMAC verification\n' >&2
SKIP_HMAC=1
fi
# -------------------------------------------------------
# Header parsing (FORMAT.md Section 4)
# -------------------------------------------------------
# Header is 40 bytes at offset 0x00
magic_hex=$(read_hex "$ARCHIVE" 0 4)
if [ "$magic_hex" != "00ea7263" ]; then
printf 'Invalid archive: bad magic bytes (got %s)\n' "$magic_hex" >&2
exit 1
fi
version_hex=$(read_hex "$ARCHIVE" 4 1)
version=$(printf '%d' "0x${version_hex}")
if [ "$version" -ne 1 ]; then
printf 'Unsupported version: %d\n' "$version" >&2
exit 1
fi
flags_hex=$(read_hex "$ARCHIVE" 5 1)
flags=$(printf '%d' "0x${flags_hex}")
file_count=$(read_le_u16 "$ARCHIVE" 6)
toc_offset=$(read_le_u32 "$ARCHIVE" 8)
toc_size=$(read_le_u32 "$ARCHIVE" 12)
printf 'Archive: %d files\n' "$file_count"
# -------------------------------------------------------
# TOC parsing loop (FORMAT.md Section 5)
# -------------------------------------------------------
pos=$toc_offset
extracted=0
i=0
while [ "$i" -lt "$file_count" ]; do
# -- name_length (u16 LE) --
name_length=$(read_le_u16 "$ARCHIVE" "$pos")
pos=$((pos + 2))
# -- filename (raw UTF-8 bytes) --
filename=$(dd if="$ARCHIVE" bs=1 skip="$pos" count="$name_length" 2>/dev/null)
pos=$((pos + name_length))
# -- original_size (u32 LE) --
original_size=$(read_le_u32 "$ARCHIVE" "$pos")
pos=$((pos + 4))
# -- compressed_size (u32 LE) --
compressed_size=$(read_le_u32 "$ARCHIVE" "$pos")
pos=$((pos + 4))
# -- encrypted_size (u32 LE) --
encrypted_size=$(read_le_u32 "$ARCHIVE" "$pos")
pos=$((pos + 4))
# -- data_offset (u32 LE) --
data_offset=$(read_le_u32 "$ARCHIVE" "$pos")
pos=$((pos + 4))
# -- iv (16 bytes as hex) --
iv_toc_pos=$pos
iv_hex=$(read_hex "$ARCHIVE" "$pos" 16)
pos=$((pos + 16))
# -- hmac (32 bytes as hex) --
hmac_hex=$(read_hex "$ARCHIVE" "$pos" 32)
pos=$((pos + 32))
# -- sha256 (32 bytes as hex) --
sha256_hex=$(read_hex "$ARCHIVE" "$pos" 32)
pos=$((pos + 32))
# -- compression_flag (1 byte as hex) --
compression_flag=$(read_hex "$ARCHIVE" "$pos" 1)
pos=$((pos + 1))
# -- padding_after (u16 LE) --
padding_after=$(read_le_u16 "$ARCHIVE" "$pos")
pos=$((pos + 2))
# =======================================================
# Per-file decode pipeline (FORMAT.md Section 10)
# =======================================================
# a. Extract ciphertext to temp file
dd if="$ARCHIVE" bs=1 skip="$data_offset" count="$encrypted_size" of="$TMPDIR/ct.bin" 2>/dev/null
# b. Verify HMAC (if available)
if [ "$SKIP_HMAC" = "0" ]; then
computed_hmac=$( {
dd if="$ARCHIVE" bs=1 skip="$iv_toc_pos" count=16 2>/dev/null
cat "$TMPDIR/ct.bin"
} | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${KEY_HEX}" -hex 2>/dev/null | awk '{print $NF}' )
# Normalize both to lowercase
computed_hmac=$(printf '%s' "$computed_hmac" | tr 'A-F' 'a-f')
hmac_hex_lc=$(printf '%s' "$hmac_hex" | tr 'A-F' 'a-f')
if [ "$computed_hmac" != "$hmac_hex_lc" ]; then
printf 'HMAC FAILED for %s, skipping\n' "$filename" >&2
i=$((i + 1))
continue
fi
fi
# c. Decrypt
openssl enc -d -aes-256-cbc -nosalt \
-K "$KEY_HEX" -iv "$iv_hex" \
-in "$TMPDIR/ct.bin" -out "$TMPDIR/dec.bin"
# d. Decompress (if compression_flag = "01")
if [ "$original_size" -eq 0 ]; then
# Special case: empty file
: > "$TMPDIR/out.bin"
elif [ "$compression_flag" = "01" ]; then
if ! command -v gunzip >/dev/null 2>&1; then
printf 'Error: gunzip required for compressed file: %s\n' "$filename" >&2
i=$((i + 1))
continue
fi
gunzip -c "$TMPDIR/dec.bin" > "$TMPDIR/out.bin"
else
mv "$TMPDIR/dec.bin" "$TMPDIR/out.bin"
fi
# e. Verify SHA-256
actual_sha=$(sha256sum "$TMPDIR/out.bin" | awk '{print $1}')
if [ "$actual_sha" != "$sha256_hex" ]; then
printf 'WARNING: SHA-256 mismatch for %s\n' "$filename" >&2
fi
# f. Write output file
mv "$TMPDIR/out.bin" "$OUTPUT_DIR/$filename"
printf 'Extracted: %s (%d bytes)\n' "$filename" "$original_size"
extracted=$((extracted + 1))
# Clean up temp files for next iteration
rm -f "$TMPDIR/ct.bin" "$TMPDIR/dec.bin"
i=$((i + 1))
done
# -------------------------------------------------------
# Final summary
# -------------------------------------------------------
printf 'Done: extracted %d files to %s\n' "$extracted" "$OUTPUT_DIR"