diff --git a/shell/decode.sh b/shell/decode.sh new file mode 100755 index 0000000..aaab589 --- /dev/null +++ b/shell/decode.sh @@ -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 + +set -e + +export LC_ALL=C + +# ------------------------------------------------------- +# Usage check +# ------------------------------------------------------- +if [ $# -ne 2 ]; then + printf 'Usage: %s \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 +# Returns lowercase hex string of bytes at +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 +# 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 +# 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"