Created
March 2, 2026 10:15
-
-
Save maxried/ca32d78ef20c85058817d35a389a9b82 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Badly tested script, mostly invented by ChatGPT. Tries to created a CSR with a new key that mimic a certificate used by a reachable server. Use this to generate a new CSR. Use it at your own risk, do not pay for certificates unless you understand what you are doing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Create a fresh private key + CSR by copying Subject and SANs from a live TLS certificate | |
| # fetched via `openssl s_client`. The new key matches the *public-key algorithm/parameters* | |
| # of the existing cert (EC curve / RSA bit length / Ed25519 / Ed448). | |
| # | |
| # Half-arse tested ChatGPT produce. Might spawn monsters in nearby. Use at your own risk. | |
| # | |
| # Input formats: | |
| # - hostname | |
| # - hostname:port | |
| # Default port: 443 | |
| # | |
| # Optional: | |
| # --starttls <proto> Use STARTTLS mode in s_client | |
| # | |
| # Outputs): | |
| # <cn>.key.pem[.<N>] | |
| # <cn>.csr.pem[.<N>] | |
| # | |
| set -euo pipefail | |
| die() { echo "ERROR: $*" >&2; exit 2; } | |
| openssl_has() { | |
| local subcmd="$1" pattern="$2" | |
| openssl "${subcmd}" -help 2>&1 | grep -qE "${pattern}" | |
| } | |
| list_starttls_values() { | |
| if ! openssl_has s_client ' -starttls'; then | |
| echo " <not supported by this OpenSSL build>" | |
| return 0 | |
| fi | |
| local out vals | |
| out="$(openssl s_client -starttls ? 2>&1 || true)" | |
| vals="$( | |
| echo "$out" \ | |
| | sed -n '/Value must be one of:/,$p' \ | |
| | sed -e '1d' -e 's/^[[:space:]]\+//' \ | |
| | grep -E '^[a-z0-9][a-z0-9-]*$' || true | |
| )" | |
| if [[ -n "$vals" ]]; then | |
| echo "$vals" | sed 's/^/ /' | |
| else | |
| echo " <could not parse values; raw output follows>" | |
| echo "$out" | sed 's/^/ /' | |
| fi | |
| } | |
| usage() { | |
| cat <<'EOF' | |
| create a new key + CSR from a live TLS certificate | |
| USAGE | |
| EOF | |
| echo " $0" '[--starttls <proto>] host[:port]' | |
| cat <<'EOF' | |
| OPTIONS | |
| -h, --help | |
| Show this help and exit | |
| --starttls <proto> | |
| Use OpenSSL s_client STARTTLS mode (passed as: -starttls <proto>) | |
| EOF | |
| echo | |
| echo "STARTTLS values supported by your OpenSSL (from: openssl s_client -starttls ?):" | |
| list_starttls_values | |
| } | |
| sanitize_name() { | |
| local s="${1:-}" | |
| s="$(printf '%s' "$s" | tr '[:upper:]' '[:lower:]')" | |
| s="$(printf '%s' "$s" | sed 's/[^a-z0-9._-]/-/g; s/-\{2,\}/-/g; s/^\([-._]\+\)//; s/\([-._]\+\)$//')" | |
| printf '%s' "${s:-}" | |
| } | |
| pick_pair_paths() { | |
| local base="$1" | |
| local i=0 key csr | |
| while :; do | |
| if [[ $i -eq 0 ]]; then | |
| key="${base}.key.pem" | |
| csr="${base}.csr.pem" | |
| else | |
| key="${base}.key.pem.${i}" | |
| csr="${base}.csr.pem.${i}" | |
| fi | |
| if [[ ! -e "$key" && ! -e "$csr" ]]; then | |
| printf '%s\n' "$key" "$csr" | |
| return 0 | |
| fi | |
| i=$((i+1)) | |
| done | |
| } | |
| atomic_copy_mode() { | |
| local src="$1" dest="$2" mode="$3" | |
| [[ ! -e "$dest" ]] || die "Refusing to overwrite existing file: $dest" | |
| local d bn tmp | |
| d="$(dirname -- "$dest")" | |
| bn="$(basename -- "$dest")" | |
| tmp="$(mktemp -p "$d" ".${bn}.tmp.XXXXXXXX")" || die "mktemp failed in dest dir: $d" | |
| local cleanup_tmp=1 | |
| trap '[[ ${cleanup_tmp} -eq 1 ]] && rm -f -- "$tmp"' RETURN | |
| cp -- "$src" "$tmp" | |
| chmod "$mode" "$tmp" | |
| mv -n -- "$tmp" "$dest" || die "Failed to move into place (destination exists?): $dest" | |
| cleanup_tmp=0 | |
| trap - RETURN | |
| } | |
| parse_target() { | |
| # Prints: HOST PORT CONNECT SNI_HOST | |
| local t="$1" | |
| local host port connect sni | |
| port="443" | |
| if [[ "$t" =~ ^\[([0-9a-fA-F:]+)\](:([0-9]+))?$ ]]; then | |
| host="${BASH_REMATCH[1]}" | |
| if [[ -n "${BASH_REMATCH[3]:-}" ]]; then | |
| port="${BASH_REMATCH[3]}" | |
| fi | |
| connect="[${host}]:${port}" | |
| sni="" | |
| else | |
| host="$t" | |
| if [[ "$t" == *:* ]]; then | |
| local maybe_port="${t##*:}" | |
| local maybe_host="${t%:*}" | |
| if [[ "$maybe_port" =~ ^[0-9]+$ ]]; then | |
| host="$maybe_host" | |
| port="$maybe_port" | |
| fi | |
| fi | |
| connect="${host}:${port}" | |
| sni="${host}" | |
| fi | |
| [[ -n "$host" ]] || die "Host is empty." | |
| [[ "$port" =~ ^[0-9]+$ ]] || die "Port is not numeric: $port" | |
| printf '%s\n' "$host" "$port" "$connect" "$sni" | |
| } | |
| count_san_entries() { | |
| local s="${1:-}" | |
| [[ -n "$s" ]] || { echo 0; return; } | |
| local n=1 tmp="$s" | |
| while [[ "$tmp" == *","* ]]; do | |
| tmp="${tmp#*,}" | |
| n=$((n+1)) | |
| done | |
| echo "$n" | |
| } | |
| detect_key_type_and_param() { | |
| local text; text="$(cat)" | |
| if echo "$text" | grep -qiE 'ED25519'; then | |
| echo "ED25519"; echo ""; return 0 | |
| fi | |
| if echo "$text" | grep -qiE 'ED448'; then | |
| echo "ED448"; echo ""; return 0 | |
| fi | |
| local ec_oid ec_nist | |
| ec_oid="$(echo "$text" | sed -n 's/^[[:space:]]*ASN1 OID:[[:space:]]*//p' | head -n1)" | |
| ec_nist="$(echo "$text" | sed -n 's/^[[:space:]]*NIST CURVE:[[:space:]]*//p' | head -n1)" | |
| if [[ -n "$ec_oid" || -n "$ec_nist" ]]; then | |
| echo "EC"; echo "${ec_oid:-$ec_nist}"; return 0 | |
| fi | |
| # RSA: Modulus+Exponent markers are reliable even when "RSA Public-Key" is absent. | |
| if echo "$text" | grep -qE '^[[:space:]]*Modulus:' && echo "$text" | grep -qE '^[[:space:]]*Exponent:'; then | |
| local bits | |
| bits="$(echo "$text" | sed -n 's/^[[:space:]]*Public-Key: (\([0-9]\+\) bit).*/\1/p' | head -n1)" | |
| echo "RSA"; echo "${bits:-}"; return 0 | |
| fi | |
| echo "UNKNOWN"; echo ""; return 0 | |
| } | |
| # ---- args ---- | |
| STARTTLS_PROTO="" | |
| TARGET="" | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -h|--help) usage; exit 0 ;; | |
| --starttls) shift; [[ $# -gt 0 ]] || die "--starttls requires an argument"; STARTTLS_PROTO="$1" ;; | |
| --starttls=*) STARTTLS_PROTO="${1#*=}" ;; | |
| --) shift; break ;; | |
| -*) die "Unknown option: $1" ;; | |
| *) [[ -z "$TARGET" ]] && TARGET="$1" || die "Unexpected extra argument: $1" ;; | |
| esac | |
| shift | |
| done | |
| if [[ -z "$TARGET" && $# -gt 0 ]]; then TARGET="$1"; fi | |
| [[ -n "$TARGET" ]] || { usage >&2; exit 1; } | |
| readarray -t PARSED < <(parse_target "$TARGET") | |
| HOST="${PARSED[0]}" | |
| PORT="${PARSED[1]}" | |
| CONNECT="${PARSED[2]}" | |
| SNI_HOST="${PARSED[3]}" | |
| # ---- preflight: non-trivial capabilities ---- | |
| command -v openssl >/dev/null 2>&1 || die "Missing required command: openssl" | |
| openssl version >/dev/null 2>&1 || die "OpenSSL is not runnable." | |
| openssl_has s_client ' -connect' || die "OpenSSL s_client does not support -connect." | |
| openssl_has s_client ' -showcerts' || die "OpenSSL s_client does not support -showcerts." | |
| openssl_has x509 ' -ext ' || die "OpenSSL x509 does not support -ext (required for SAN extraction)." | |
| openssl_has x509 ' -pubkey' || die "OpenSSL x509 does not support -pubkey." | |
| openssl_has pkey ' -pubin' || die "OpenSSL pkey does not support -pubin." | |
| openssl_has pkey ' -text' || die "OpenSSL pkey does not support -text." | |
| openssl_has req ' -subj' || die "OpenSSL req does not support -subj." | |
| openssl_has req ' -config' || die "OpenSSL req does not support -config." | |
| openssl_has genpkey '' || die "OpenSSL genpkey subcommand not available." | |
| if [[ -n "$SNI_HOST" ]]; then | |
| openssl_has s_client ' -servername' || die "OpenSSL s_client does not support -servername (SNI)." | |
| fi | |
| if [[ -n "$STARTTLS_PROTO" ]]; then | |
| openssl_has s_client ' -starttls' || die "OpenSSL s_client does not support -starttls." | |
| fi | |
| command -v mktemp >/dev/null 2>&1 || die "Missing required command: mktemp" | |
| # ---- temp workspace ---- | |
| tmpdir="$(mktemp -d -t csr.XXXXXXXX)" || die "mktemp failed." | |
| cleanup() { rm -rf "${tmpdir}"; } | |
| trap cleanup EXIT | |
| OLD_CERT="${tmpdir}/old-cert.pem" | |
| PUBKEY_PEM="${tmpdir}/pubkey.pem" | |
| CSR_CONF="${tmpdir}/csr.conf" | |
| TMP_KEY="${tmpdir}/new.key.pem" | |
| TMP_CSR="${tmpdir}/new.csr.pem" | |
| # ---- 1) fetch leaf cert ---- | |
| SCLIENT_ARGS=( -connect "${CONNECT}" -showcerts ) | |
| [[ -n "$SNI_HOST" ]] && SCLIENT_ARGS+=( -servername "${SNI_HOST}" ) | |
| [[ -n "$STARTTLS_PROTO" ]] && SCLIENT_ARGS+=( -starttls "${STARTTLS_PROTO}" ) | |
| openssl s_client "${SCLIENT_ARGS[@]}" </dev/null \ | |
| | openssl x509 -outform PEM > "${OLD_CERT}" | |
| # ---- 2) subject + CN ---- | |
| SUBJECT_RFC2253="$(openssl x509 -in "${OLD_CERT}" -noout -subject -nameopt RFC2253 | sed 's/^subject=//')" | |
| SUBJECT_SLASH="/$(echo "${SUBJECT_RFC2253}" | sed 's/,/\//g')" | |
| CN_RAW="$(openssl x509 -in "${OLD_CERT}" -noout -subject -nameopt RFC2253 \ | |
| | sed -n 's/^subject=.*CN=\([^,]*\).*$/\1/p' | head -n1)" | |
| CN_RAW="${CN_RAW:-$HOST}" | |
| CN_SAFE="$(sanitize_name "${CN_RAW}")" | |
| [[ -n "${CN_SAFE}" ]] || CN_SAFE="$(sanitize_name "${HOST}")" | |
| [[ -n "${CN_SAFE}" ]] || CN_SAFE="cert" | |
| readarray -t OUTS < <(pick_pair_paths "${CN_SAFE}") | |
| KEY_OUT="${OUTS[0]}" | |
| CSR_OUT="${OUTS[1]}" | |
| # ---- 3) SANs ---- | |
| SAN_CSV="$( | |
| openssl x509 -in "${OLD_CERT}" -noout -ext subjectAltName 2>/dev/null \ | |
| | sed -n '2,$p' \ | |
| | sed 's/^[[:space:]]\+//' \ | |
| | tr -d ' \t\r' \ | |
| | tr -d '\n' \ | |
| | sed -e 's/IP Address:/IP:/g' -e 's/IPAddress:/IP:/g' -e 's/,$//' | |
| )" | |
| SAN_COUNT="$(count_san_entries "${SAN_CSV}")" | |
| # ---- 4) determine public key algorithm/params ---- | |
| openssl x509 -in "${OLD_CERT}" -noout -pubkey > "${PUBKEY_PEM}" | |
| PKEY_TEXT="$(openssl pkey -pubin -in "${PUBKEY_PEM}" -text -noout 2>/dev/null)" | |
| readarray -t KP < <(printf '%s' "$PKEY_TEXT" | detect_key_type_and_param) | |
| DETECTED_ALGO="${KP[0]}" | |
| DETECTED_PARAM="${KP[1]}" | |
| [[ "$DETECTED_ALGO" != "UNKNOWN" ]] || die "Unknown/unexpected public key algorithm." | |
| # ---- 5) generate matching key ---- | |
| case "$DETECTED_ALGO" in | |
| ED25519) openssl genpkey -algorithm ED25519 -out "${TMP_KEY}" ;; | |
| ED448) openssl genpkey -algorithm ED448 -out "${TMP_KEY}" ;; | |
| EC) | |
| [[ -n "$DETECTED_PARAM" ]] || die "EC detected but curve could not be determined." | |
| openssl genpkey -algorithm EC -pkeyopt "ec_paramgen_curve:${DETECTED_PARAM}" -out "${TMP_KEY}" | |
| ;; | |
| RSA) | |
| [[ "$DETECTED_PARAM" =~ ^[0-9]+$ ]] || die "RSA detected but bit length missing/unparseable." | |
| openssl genpkey -algorithm RSA -pkeyopt "rsa_keygen_bits:${DETECTED_PARAM}" -out "${TMP_KEY}" | |
| ;; | |
| *) die "Internal error: unsupported algo '$DETECTED_ALGO'" ;; | |
| esac | |
| [[ ! -e "${KEY_OUT}" && ! -e "${CSR_OUT}" ]] || die "Target files already exist: ${KEY_OUT} / ${CSR_OUT}" | |
| atomic_copy_mode "${TMP_KEY}" "${KEY_OUT}" 600 | |
| # ---- 6) minimal CSR config ---- | |
| cat > "${CSR_CONF}" <<'EOF' | |
| [ req ] | |
| prompt = no | |
| distinguished_name = dn | |
| req_extensions = req_ext | |
| [ dn ] | |
| # Subject is provided via -subj | |
| [ req_ext ] | |
| EOF | |
| [[ -n "${SAN_CSV}" ]] && echo "subjectAltName = ${SAN_CSV}" >> "${CSR_CONF}" | |
| # ---- 7) create CSR ---- | |
| openssl req -new -key "${KEY_OUT}" -out "${TMP_CSR}" \ | |
| -subj "${SUBJECT_SLASH}" \ | |
| -config "${CSR_CONF}" | |
| atomic_copy_mode "${TMP_CSR}" "${CSR_OUT}" 644 | |
| # ---- 8) summary ---- | |
| algo_pretty="$DETECTED_ALGO" | |
| [[ "$DETECTED_ALGO" == "EC" ]] && algo_pretty="EC (${DETECTED_PARAM})" | |
| [[ "$DETECTED_ALGO" == "RSA" ]] && algo_pretty="RSA (${DETECTED_PARAM} bits)" | |
| echo "=== Detected / Derived ===" | |
| echo "Connected to : ${CONNECT}" | |
| echo "STARTTLS : ${STARTTLS_PROTO:-<none>}" | |
| echo "SNI servername used : ${SNI_HOST:-<none>}" | |
| echo "Certificate subject : ${SUBJECT_RFC2253}" | |
| echo "Public-key algorithm : ${algo_pretty}" | |
| echo "SAN entry count : ${SAN_COUNT}" | |
| [[ -n "${SAN_CSV}" ]] && echo "SAN list : ${SAN_CSV}" | |
| echo | |
| echo "=== Output files ===" | |
| echo "Private key : ${KEY_OUT}" | |
| echo "CSR : ${CSR_OUT}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment