#!/bin/bash
# Sweep soft (file-based) client certificates against the mTLS test ports.
#
# Expects certs named by the get-cert.html convention:
#   <name>-<leafkeytype>-ca-<catype>.crt / .key   e.g. erikalpha8-mldsa-ca-rsa.crt
#                ^ client leaf key   ^ CA that signed it
# and the root CAs in the same directory.
#
# Usage: ./test-soft-certs.sh <name> [-n samples] [port ...]
#   ./test-soft-certs.sh erikalpha8              # all ports, single sample
#   ./test-soft-certs.sh erikalpha8 -n 3         # all ports, avg of 3 samples
#   ./test-soft-certs.sh erikalpha8 8447         # one port
#   ./test-soft-certs.sh erikalpha8 -n 5 8447    # one port, avg of 5 samples
#
# Needs OpenSSL 3.5+ for the ML-DSA cases.

set -u

NAME="${1:-}"
if [ -z "$NAME" ]; then
    echo "Usage: $0 <name> [-n samples] [port ...]"
    exit 1
fi
shift

SAMPLES=1
PORTS_EXPLICIT=()
while [[ $# -gt 0 ]]; do
    case "$1" in
        -n) SAMPLES="${2:-1}"; shift 2 ;;
        *)  PORTS_EXPLICIT+=("$1"); shift ;;
    esac
done

HOST=sctest.yksi.me
OPENSSL="${OPENSSL_BIN:-openssl}"
KEY_TYPES="rsa ecdsa mldsa mldsa65 mldsa87"

declare -A CAFILE=(
    [ca-rsa]=YK-RSA-Root-CA.crt
    [ca-ecdsa]=YK-ECDSA-Root-CA.crt
    [ca-mldsa]=YK-ML-DSA-44-Root-CA.crt
    [ca-mldsa65]=YK-ML-DSA-65-Root-CA.crt
    [ca-mldsa87]=YK-ML-DSA-87-Root-CA.crt
)

# Which CA must have signed a CLIENT certificate for this port to accept it.
declare -A PORT_CA=(
    [8443]=ca-rsa
    [8444]=ca-mldsa
    [8445]=ca-ecdsa
    [8446]=ca-mldsa
    [8447]=ca-mldsa
    [8448]=ca-rsa
    [8449]=ca-mldsa
    [8450]=ca-mldsa65
    [8451]=ca-mldsa87
)

# Which root CA we need in order to verify the SERVER's chain. On six of the
# seven ports this is the same CA as above, but 8449 deliberately splits them:
# a classical RSA server chain that any browser can validate, paired with an
# ML-DSA client CA. Using PORT_CA for both would fail 8449 for the wrong reason.
declare -A PORT_TRUST=(
    [8443]=ca-rsa
    [8444]=ca-mldsa
    [8445]=ca-ecdsa
    [8446]=ca-mldsa
    [8447]=ca-mldsa
    [8448]=ca-rsa
    [8449]=ca-rsa
    [8450]=ca-mldsa65
    [8451]=ca-mldsa87
)

# The key algorithm in the SERVER's own leaf certificate.
declare -A PORT_SERVER_LEAF=(
    [8443]="RSA-2048"
    [8444]="RSA-2048"
    [8445]="ECDSA P-256"
    [8446]="ECDSA P-256"
    [8447]="ML-DSA-44"
    [8448]="RSA-2048"
    [8449]="RSA-2048"
    [8450]="ML-DSA-65"
    [8451]="ML-DSA-87"
)

declare -A PORT_CA_ALG=(
    [8443]="RSA"
    [8444]="ML-DSA-44"
    [8445]="ECDSA"
    [8446]="ML-DSA-44"
    [8447]="ML-DSA-44"
    [8448]="RSA"
    [8449]="RSA"
    [8450]="ML-DSA-65"
    [8451]="ML-DSA-87"
)

declare -A PORT_KX=(
    [8443]="hybrid PQ (X25519MLKEM768)"
    [8444]="hybrid PQ (X25519MLKEM768)"
    [8445]="hybrid PQ (X25519MLKEM768)"
    [8446]="hybrid PQ (X25519MLKEM768)"
    [8447]="hybrid PQ (X25519MLKEM768)"
    [8448]="classical (X25519 / P-256)"
    [8449]="hybrid PQ (X25519MLKEM768)"
    [8450]="hybrid PQ (X25519MLKEM768)"
    [8451]="hybrid PQ (X25519MLKEM768)"
)

# Fisher-Yates shuffle: randomise port order so no port is systematically
# cold (first run) or warm (last run) across all test invocations.
shuffle_array() {
    local -n _arr=$1
    local n=${#_arr[@]}
    for (( i = n-1; i > 0; i-- )); do
        local j=$(( RANDOM % (i+1) ))
        local tmp="${_arr[i]}"
        _arr[i]="${_arr[j]}"
        _arr[j]="$tmp"
    done
}

PORTS=("${PORTS_EXPLICIT[@]}")
if [ ${#PORTS[@]} -eq 0 ]; then
    PORTS=(8443 8444 8445 8446 8447 8448 8449 8450 8451)
    shuffle_array PORTS
fi

pass=0; fail=0; skip=0; bad=0

# Per-run TSV log of every individual sample (port, label, sample#, ms).
# Lets you spot outliers without trusting just the median summary.
RUN_TS=$(date +%Y%m%d-%H%M%S 2>/dev/null || date +%s)
LOGFILE="${NAME}-${RUN_TS}.tsv"
printf 'run_ts\tname\tport\tlabel\tsample\telapsed_ms\n' > "$LOGFILE"
echo "Raw samples → $LOGFILE" >&2

# label(32) verdict(7) outcome(8) proto(9) kex(17) server_leaf(16) certsize(9) time
HDR_FMT='  %-32s %-7s %-8s %-9s %-17s %-16s %-9s %s\n'

legend() {
    cat <<'EOF'

What each column means:
  CLIENT CERT (Leaf + CA)  the client's own leaf key type, and the CA that signed
                           it - both read straight out of the filename
  VERDICT                  PASS = this port behaved the way this combination should
                           BASE = baseline with no client certificate (9xxx port)
  OUTCOME                  accept = server completed mTLS and served a response
                           reject = server refused the client certificate
  KEY EXCHANGE             TLS 1.3 group actually negotiated, i.e. whether the
                           channel is hybrid post-quantum or classical
  SERVER LEAF SIG          algorithm the SERVER used to sign CertificateVerify with
                           its own leaf key. Not the CA's algorithm, and not yours.
  CERT SIZE                bytes in the client certificate PEM file — larger certs
                           mean more bytes on the wire and more work for the server
  TIME                     wall-clock ms for the full TLS handshake + HTTP round-trip
                           (includes network latency; -n>1 shows median then min-max)

  [baseline row]           connects to the mirrored 9xxx port with no client cert —
                           subtract this from an accept row to isolate client cert
                           verification overhead from network + key exchange cost

Each port below is one server configuration. Ports sharing a CA (8444/8446/8447)
differ only in the server's leaf key, which is what SERVER LEAF SIG reveals.
EOF
}

# Return a time summary string from an array of millisecond samples.
#   N=1  →  "214ms"
#   N>1  →  "214ms(209-224)"   (median shown, min-max in parens)
time_summary() {
    local times=("$@")
    local n=${#times[@]}
    if [ "$n" -eq 0 ]; then echo "-"; return; fi

    local min=${times[0]} max=${times[0]}
    for t in "${times[@]}"; do
        [ "$t" -lt "$min" ] && min=$t
        [ "$t" -gt "$max" ] && max=$t
    done

    # Median: sort numerically, pick middle (or integer average of two middles)
    local sorted
    mapfile -t sorted < <(printf '%s\n' "${times[@]}" | sort -n)
    local mid=$(( n / 2 ))
    local median
    if (( n % 2 == 1 )); then
        median=${sorted[$mid]}
    else
        median=$(( (sorted[$((mid-1))] + sorted[$mid]) / 2 ))
    fi

    if [ "$n" -eq 1 ]; then
        echo "${median}ms"
    else
        echo "${median}ms(${min}-${max})"
    fi
}

# Connect to the mirrored 9xxx port with NO client certificate.
# This isolates: network RTT + key exchange + server cert verification.
# Subtract from accept rows to get pure client-cert overhead.
probe_base() {
    local port=$1 cafile=$2
    local baseport=$(( port + 1000 ))
    local times=() t0 t1 out proto group sigtype

    for ((i=0; i<SAMPLES; i++)); do
        t0=$(date +%s%3N 2>/dev/null || echo "")
        out=$(printf 'GET / HTTP/1.0\r\n\r\n' \
              | "$OPENSSL" s_client -connect "$HOST:$baseport" -brief -ign_eof \
                  -CAfile "$cafile" \
                  -verify_return_error 2>&1)
        t1=$(date +%s%3N 2>/dev/null || echo "")
        if [[ -n "$t0" && -n "$t1" && "$t0" =~ ^[0-9]+$ && "$t1" =~ ^[0-9]+$ ]]; then
            local elapsed=$(( t1 - t0 ))
            times+=("$elapsed")
            printf '%s\t%s\t%s\t%s\t%d\t%d\n' \
                "$RUN_TS" "$NAME" "$baseport" "baseline" "$i" "$elapsed" >> "$LOGFILE"
        fi
    done

    proto=$(grep -oP 'Protocol version: \K.*' <<<"$out" | head -1)
    group=$(grep -oP 'Negotiated TLS1.3 group: \K.*' <<<"$out" | head -1)
    sigtype=$(grep -oP '(?:Peer signature|Signature) type: \K.*' <<<"$out" | head -1)

    local elapsed
    elapsed=$(time_summary "${times[@]}")

    printf "$HDR_FMT" \
        "baseline: no cert (port $baseport)" "BASE" "accept" \
        "${proto:--}" "${group:--}" "${sigtype:--}" "-" "$elapsed"
}

# Run one handshake. Reports PASS/FAIL against what we expect to happen.
#
# IMPORTANT: under TLS 1.3 the client finishes its half of the handshake and
# prints CONNECTION ESTABLISHED *before* the server has validated the client
# certificate. A rejection comes back as alert 48 only once data actually
# moves. So we must push an HTTP request through the connection and require a
# clean exit -- testing the handshake alone reports every rejected cert as a
# success. (And "Verification: OK" is us verifying the SERVER, which tells us
# nothing about whether the server accepted US.)
probe() {
    local port=$1 slug=$2 cafile=$3 expect=$4 label=$5
    local out rc

    if [ ! -f "$slug.crt" ] || [ ! -f "$slug.key" ]; then
        printf '  %-32s %s\n' "$label" "SKIP (no cert/key on disk: $slug)"
        skip=$((skip+1))
        return
    fi

    # Pre-flight: a mismatched pair fails locally before any packet is sent,
    # which is a generation mistake rather than a test result. Catch it here
    # so it does not read as a server-side rejection.
    local certpub keypub
    certpub=$("$OPENSSL" x509 -in "$slug.crt" -noout -pubkey 2>/dev/null | "$OPENSSL" sha256)
    keypub=$("$OPENSSL" pkey -in "$slug.key" -pubout 2>/dev/null | "$OPENSSL" sha256)
    if [ -z "$certpub" ] || [ "$certpub" != "$keypub" ]; then
        printf '  %-32s %-7s %s\n' "$label" "BADPAIR" \
            "private key does not match certificate - regenerate $slug"
        bad=$((bad+1))
        return
    fi

    # Client cert file size (what gets sent on the wire)
    local certsize="-"
    local bytes
    bytes=$(wc -c < "$slug.crt" 2>/dev/null || echo 0)
    if [ "$bytes" -gt 1024 ]; then
        certsize="$(( bytes / 1024 )).$(( (bytes % 1024) * 10 / 1024 ))K"
    else
        certsize="${bytes}B"
    fi

    local times=() t0 t1
    for ((i=0; i<SAMPLES; i++)); do
        t0=$(date +%s%3N 2>/dev/null || echo "")
        out=$(printf 'GET / HTTP/1.0\r\n\r\n' \
              | "$OPENSSL" s_client -connect "$HOST:$port" -brief -ign_eof \
                  -cert "$slug.crt" -key "$slug.key" -CAfile "$cafile" \
                  -verify_return_error 2>&1)
        rc=$?
        t1=$(date +%s%3N 2>/dev/null || echo "")
        if [[ -n "$t0" && -n "$t1" && "$t0" =~ ^[0-9]+$ && "$t1" =~ ^[0-9]+$ ]]; then
            local elapsed=$(( t1 - t0 ))
            times+=("$elapsed")
            printf '%s\t%s\t%s\t%s\t%d\t%d\n' \
                "$RUN_TS" "$NAME" "$port" "$label" "$i" "$elapsed" >> "$LOGFILE"
        fi
    done

    local outcome
    if [ $rc -eq 0 ] && ! grep -qi "alert" <<<"$out"; then
        outcome=accept
    else
        outcome=reject
    fi

    local proto group sigtype verdict
    proto=$(grep -oP 'Protocol version: \K.*' <<<"$out" | head -1)
    group=$(grep -oP 'Negotiated TLS1.3 group: \K.*' <<<"$out" | head -1)
    # -brief prints "Signature type:"; full output prints "Peer signature type:"
    sigtype=$(grep -oP '(?:Peer signature|Signature) type: \K.*' <<<"$out" | head -1)

    if [ "$outcome" = "$expect" ]; then
        verdict="PASS"; pass=$((pass+1))
    else
        verdict="FAIL"; fail=$((fail+1))
    fi

    local elapsed
    elapsed=$(time_summary "${times[@]}")

    printf "$HDR_FMT" \
        "$label" "$verdict" "$outcome" "${proto:--}" "${group:--}" "${sigtype:--}" "$certsize" "$elapsed"

    if [ "$verdict" = "FAIL" ]; then
        # Fall back to the raw output if nothing matches the usual patterns,
        # so an unanticipated message is never swallowed.
        grep -iE "alert|verify error|error:|does not match|unable to" <<<"$out" \
            | sed 's/^/      /' | head -3 \
            || true
        if ! grep -qiE "alert|verify error|error:|does not match|unable to" <<<"$out"; then
            grep -v '^[[:space:]]*$' <<<"$out" | sed 's/^/      /' | head -3
        fi
    fi
}

legend

for port in "${PORTS[@]}"; do
    ca="${PORT_CA[$port]:-}"
    if [ -z "$ca" ]; then
        echo "Unknown port: $port"
        continue
    fi

    trust="${PORT_TRUST[$port]}"
    trustfile="${CAFILE[$trust]}"

    echo ""
    echo "=== Port $port ======================================================="
    echo "  Server leaf cert : ${PORT_SERVER_LEAF[$port]}  (signed by the ${PORT_CA_ALG[$port]} CA)"
    echo "  Key exchange     : ${PORT_KX[$port]}"
    echo "  Verify server w/ : $trustfile"
    echo "  Accepts clients  : signed by $ca"
    if [ "$trust" != "$ca" ]; then
        echo "  NOTE             : server trust and client CA are deliberately different here"
    fi
    echo ""
    printf "$HDR_FMT" \
        "CLIENT CERT (Leaf + CA)" "VERDICT" "OUTCOME" "PROTO" "KEY EXCHANGE" "SERVER LEAF SIG" "CERT SIZE" "TIME"

    # Baseline: same server config but no client cert, via the mirrored 9xxx port
    probe_base "$port" "$trustfile"

    for kt in $KEY_TYPES; do
        # Positive: client leaf signed by the CA this port trusts.
        probe "$port" "$NAME-$kt-$ca" "$trustfile" accept "$kt leaf + ${ca#ca-} CA"
    done

    for other in ca-rsa ca-ecdsa ca-mldsa ca-mldsa65 ca-mldsa87; do
        [ "$other" = "$ca" ] && continue
        # Negative: valid leaf, wrong CA. Server should reject (alert 48).
        probe "$port" "$NAME-mldsa-$other" "$trustfile" reject \
              "mldsa leaf + ${other#ca-} CA"
    done
done

echo ""
echo "pass=$pass fail=$fail skip=$skip badpair=$bad"
[ $fail -eq 0 ] && [ $bad -eq 0 ]
