Google Geminiに

「発行/別サーバからインポートしたSSL証明書の整合性確認を行うシェルスクリプト」

を書いてほしいと尋ねました。

条件は

  • コマンド [SSL証明書の入ったディレクトリ]形式
  • 証明書は
    • ドメイン名.crt.yyyymm
    • Let's Encryptのように証明書と中間証明書が結合された形式
  • 秘密鍵は
    • ドメイン名.key.yyyymm

の2つが入ったディレクトリを想定。

できあがったスクリプト

#!/bin/bash

# SSL証明書と秘密鍵の情報を表示するスクリプト
# サーバー証明書の有効期限、ドメイン名、発行局を表示します。
# また、対応する秘密鍵の存在と一致確認も行います。

# 使用法を表示する関数
usage() {
  echo "使用法: $0 <証明書と鍵が格納されているディレクトリ>"
  echo "ディレクトリには '[ドメイン名].crt.yyyymm' のような名前の証明書ファイルを配置してください。"
  echo "また、対応する秘密鍵ファイルは '[ドメイン名].key.yyyymm' の形式です。"
  echo "証明書ファイルは、サーバー証明書と中間証明書が結合された形式を想定しています。"
  exit 1
}

# 引数が指定されているか確認
if [ -z "$1" ]; then
  usage
fi

TARGET_DIR="$1"

# ディレクトリが存在するか確認
if [ ! -d "$TARGET_DIR" ]; then
  echo "エラー: ディレクトリ '$TARGET_DIR' が見つかりません。"
  exit 1
fi

# openssl コマンドが利用可能か確認
if ! command -v openssl &> /dev/null; then
    echo "エラー: openssl コマンドが見つかりません。OpenSSLをインストールしてください。"
    exit 1
fi

echo "ディレクトリ '$TARGET_DIR' 内のSSL証明書情報:"
echo "=================================================="

CERT_FILES_FOUND=0 # 処理した証明書ファイルのカウンタを初期化

# プロセス置換を使用して、CERT_FILES_FOUND が現在のシェルで更新されるようにする
while IFS= read -r -d $'\0' CERT_FILE; do
  CERT_FILES_FOUND=$((CERT_FILES_FOUND + 1)) # カウンタを増やす
  FILENAME=$(basename "$CERT_FILE")
  echo "処理中の証明書: $FILENAME"

  # --- 有効期限 ---
  EXPIRY_DATE_FULL=$(openssl x509 -noout -enddate -in "$CERT_FILE" 2>/dev/null)
  if [ $? -ne 0 ] || [ -z "$EXPIRY_DATE_FULL" ]; then
      EXPIRY_DATE_DISPLAY="有効期限の抽出エラー"
  else
      EXPIRY_DATE_DISPLAY=$(echo "$EXPIRY_DATE_FULL" | sed 's/notAfter=//')
  fi
  echo "  有効期限: $EXPIRY_DATE_DISPLAY"

  # --- ドメイン名 (サブジェクトのコモンネーム(CN) および サブジェクト代替名(SANs)) ---
  DOMAIN_NAME_DISPLAY="N/A"
  SUBJECT_CN_RAW=$(openssl x509 -noout -subject -nameopt multiline -in "$CERT_FILE" 2>/dev/null | grep 'commonName')
  SUBJECT_CN=""
  if [ $? -eq 0 ] && [ -n "$SUBJECT_CN_RAW" ]; then # grep で commonName が見つかった場合
    SUBJECT_CN=$(echo "$SUBJECT_CN_RAW" | sed -e 's/^\s*commonName\s*=\s*//' -e 's/\s*(Default)\s*$//' | head -n 1)
  fi
  
  # SANs を取得 (もしあれば最初の SAN 行のみ)
  SANS_TEXT=$(openssl x509 -noout -text -in "$CERT_FILE" 2>/dev/null | awk '/X509v3 Subject Alternative Name:/ {found=1; getline; print; exit} END{if(!found)print ""}')
  SANS_TEXT_CLEANED=$(echo "$SANS_TEXT" | sed 's/^\s*//') # 先頭の空白を削除
  SANS_LIST=""
  PRIMARY_SAN_DOMAIN=""

  if [[ "$SANS_TEXT_CLEANED" == "DNS:"* ]]; then
      # SANsリストを整形: DNS:プレフィックスを除去、空白除去、カンマ区切り、重複除去
      SANS_LIST=$(echo "$SANS_TEXT_CLEANED" | sed 's/DNS://g' | tr -d ' ' | tr ',' '\n' | awk '!seen[$0]++' | tr '\n' ',' | sed 's/,$//')
      if [ -n "$SANS_LIST" ]; then
        PRIMARY_SAN_DOMAIN=$(echo "$SANS_LIST" | cut -d',' -f1) # 最初のSANを主要ドメインとする
      fi
  fi

  if [ -n "$PRIMARY_SAN_DOMAIN" ]; then # 主要SANがある場合
      DOMAIN_NAME_DISPLAY="$PRIMARY_SAN_DOMAIN"
      # 主要SAN以外のSANがあれば表示
      OTHER_SANS=$(echo "$SANS_LIST," | sed "s/$PRIMARY_SAN_DOMAIN,//" | sed 's/,$//') 
      if [ -n "$OTHER_SANS" ]; then
          DOMAIN_NAME_DISPLAY="$PRIMARY_SAN_DOMAIN (他のSAN: $OTHER_SANS)"
      fi
      # CNが存在し、かつ主要SANと異なる場合にCNも表示
      if [ -n "$SUBJECT_CN" ] && [ "$SUBJECT_CN" != "$PRIMARY_SAN_DOMAIN" ]; then
          DOMAIN_NAME_DISPLAY="$DOMAIN_NAME_DISPLAY [CNも存在: $SUBJECT_CN]"
      fi
  elif [ -n "$SUBJECT_CN" ]; then # 主要SANが無く、CNがある場合
      DOMAIN_NAME_DISPLAY="$SUBJECT_CN"
      if [ -n "$SANS_LIST" ]; then # SANリスト自体は存在する (CNが主要となるケース)
          IS_CN_IN_SANS=0
          # SANS_LISTを配列に変換して確認しやすくする
          IFS=',' read -r -a SAN_ARRAY <<< "$SANS_LIST"
          for san_entry in "${SAN_ARRAY[@]}"; do
            if [ "$san_entry" = "$SUBJECT_CN" ]; then IS_CN_IN_SANS=1; break; fi
          done
          
          if [ $IS_CN_IN_SANS -eq 0 ]; then # CNがSANリストに含まれていない場合
             # SANリストがCNと異なる場合にのみSANリストを表示 (CNが唯一のSANである場合を除く)
             if [ "$SANS_LIST" != "$SUBJECT_CN" ]; then
                DOMAIN_NAME_DISPLAY="$SUBJECT_CN (SAN: $SANS_LIST)" # SAN -> SANs に統一すべきか検討
             fi
          else # CNがSANリストに含まれている場合、CN以外のSANがあれば表示
             OTHER_SANS_THAN_CN=""
             TEMP_SANS_LIST=""
             for san_entry in "${SAN_ARRAY[@]}"; do
                if [ "$san_entry" != "$SUBJECT_CN" ]; then
                    TEMP_SANS_LIST="${TEMP_SANS_LIST}${san_entry},"
                fi
             done
             OTHER_SANS_THAN_CN=$(echo "$TEMP_SANS_LIST" | sed 's/,$//')

             if [ -n "$OTHER_SANS_THAN_CN" ]; then
                DOMAIN_NAME_DISPLAY="$SUBJECT_CN (他のSAN: $OTHER_SANS_THAN_CN)"
             fi
          fi
      fi
  else # CNもSANも取得できなかった場合
      DOMAIN_NAME_DISPLAY="エラー: コモンネームまたはサブジェクト代替名を抽出できませんでした。"
  fi
  echo "  ドメイン名: $DOMAIN_NAME_DISPLAY"

  # --- 発行局 (サーバー証明書を発行した中間認証局) ---
  # 証明書ファイル内の最初の証明書のIssuerを取得
  ISSUER_CN_RAW=$(openssl x509 -noout -issuer -nameopt multiline -in "$CERT_FILE" 2>/dev/null | grep 'commonName')
  ISSUER_CN="N/A"
  if [ $? -eq 0 ] && [ -n "$ISSUER_CN_RAW" ]; then # grepでcommonNameが見つかったか確認
      ISSUER_CN=$(echo "$ISSUER_CN_RAW" | sed -e 's/^\s*commonName\s*=\s*//' -e 's/\s*(Default)\s*$//' | head -n 1)
  fi
  
  ISSUER_FULL_RAW=$(openssl x509 -noout -issuer -nameopt oneline,-space_eq,-show_type -in "$CERT_FILE" 2>/dev/null)
  ISSUER_FULL="N/A"
  if [ $? -eq 0 ] && [ -n "$ISSUER_FULL_RAW" ]; then
      ISSUER_FULL=$(echo "$ISSUER_FULL_RAW" | sed 's/issuer=//')
  fi

  if [ "$ISSUER_CN" != "N/A" ]; then
    echo "  発行局 (CN): $ISSUER_CN"
    echo "  発行局 (詳細): $ISSUER_FULL"
  else
    echo "  発行局 (詳細): $ISSUER_FULL (CNが見つからないか、CNの抽出エラーです)"
  fi

  # --- 秘密鍵の確認 ---
  # 証明書ファイル名から対応する秘密鍵ファイル名を推測 (例: domain.crt.yyyymm -> domain.key.yyyymm)
  KEY_FILE_CANDIDATE_NAME=$(basename "$CERT_FILE" | sed -E 's/\.crt\.([0-9]{6})$/.key.\1/')
  POTENTIAL_KEY_FILE="$TARGET_DIR/$KEY_FILE_CANDIDATE_NAME"

  if [ -f "$POTENTIAL_KEY_FILE" ]; then
    echo "  秘密鍵ファイル: $(basename "$POTENTIAL_KEY_FILE")"
    KEY_MATCH_STATUS="未確定 (エラーまたは非対応の鍵タイプ)"
    KEY_TYPE="不明"

    CERT_PUBKEY=$(openssl x509 -noout -pubkey -in "$CERT_FILE" 2>/dev/null) # 証明書から公開鍵を抽出
    KEY_IS_ENCRYPTED_OR_BAD_FORMAT=0
    PRIV_KEY_PUBKEY="" # 初期化

    # 鍵の種類を特定し、その公開鍵コンポーネントを取得 ('openssl pkey' は汎用的な鍵読み取り試行)
    if openssl pkey -in "$POTENTIAL_KEY_FILE" -noout >/dev/null 2>&1; then # まず鍵として読めるか
        if openssl rsa -in "$POTENTIAL_KEY_FILE" -noout -check >/dev/null 2>&1; then # RSA鍵か
            KEY_TYPE="RSA"
            PRIV_KEY_PUBKEY=$(openssl rsa -in "$POTENTIAL_KEY_FILE" -pubout 2>/dev/null) # RSA秘密鍵から公開鍵を抽出
        elif openssl ec -in "$POTENTIAL_KEY_FILE" -noout -check >/dev/null 2>&1; then # EC鍵か
            KEY_TYPE="EC"
            PRIV_KEY_PUBKEY=$(openssl ec -in "$POTENTIAL_KEY_FILE" -pubout 2>/dev/null) # EC秘密鍵から公開鍵を抽出
        else # その他の鍵タイプ (DSAなど) か、上記チェックをパスしないがpkeyでは読める鍵
            KEY_TYPE="その他 ('openssl pkey'で解析可能)"
            PRIV_KEY_PUBKEY=$(openssl pkey -in "$POTENTIAL_KEY_FILE" -pubout 2>/dev/null) # 汎用的に公開鍵抽出を試行
        fi
        if [ $? -ne 0 ] || [ -z "$PRIV_KEY_PUBKEY" ]; then # 公開鍵の抽出に失敗した場合
             KEY_IS_ENCRYPTED_OR_BAD_FORMAT=1 # 暗号化されているか、形式に問題がある可能性
        fi
    else # 'openssl pkey' で解析不可の場合
        KEY_IS_ENCRYPTED_OR_BAD_FORMAT=1 # 破損または暗号化の可能性が高い
        KEY_TYPE="読み取り不可または暗号化"
    fi

    if [ "$KEY_IS_ENCRYPTED_OR_BAD_FORMAT" -eq 1 ]; then
        KEY_MATCH_STATUS="鍵の読み取りエラー (暗号化、パスワード保護、または不正な形式の可能性あり)。検出された鍵タイプ: $KEY_TYPE"
    elif [ -n "$CERT_PUBKEY" ] && [ -n "$PRIV_KEY_PUBKEY" ]; then # 証明書の公開鍵と秘密鍵由来の公開鍵が両方取得できた場合
      if [ "$CERT_PUBKEY" = "$PRIV_KEY_PUBKEY" ]; then # 公開鍵同士を比較
        KEY_MATCH_STATUS="証明書と一致 ($KEY_TYPE)"
      else
        KEY_MATCH_STATUS="証明書と不一致 ($KEY_TYPE)"
      fi
    elif [ -z "$CERT_PUBKEY" ]; then # 証明書から公開鍵が取得できなかった場合
      KEY_MATCH_STATUS="証明書からの公開鍵生成エラー"
    else # 秘密鍵ファイルから公開鍵が取得できなかった場合 (鍵は読めたが公開鍵抽出に失敗)
      KEY_MATCH_STATUS="秘密鍵ファイルからの公開鍵生成エラー ($KEY_TYPE)"
    fi
    echo "    鍵の状態: $KEY_MATCH_STATUS"
  else
    echo "  秘密鍵ファイル: $(basename "$KEY_FILE_CANDIDATE_NAME") 見つかりません"
  fi
  echo "--------------------------------------------------"
done < <(find "$TARGET_DIR" -type f -name "*.crt.*[0-9][0-9][0-9][0-9][0-9][0-9]" -print0) # プロセス置換の終了

# このチェックで、ファイルが処理されたかどうかが正しく反映される
if [ "$CERT_FILES_FOUND" -eq 0 ]; then
    echo "ディレクトリ '$TARGET_DIR' 内に *.crt.*[数字6桁] のパターンに一致する証明書ファイルは見つかりませんでした。"
    echo "(例: domain.com.crt.202312)"
fi

これを `ssl-check.sh` として保存し、実行権限を付けます。

使用例

./ssl-check.sh /ディレクトリのフルパス

で実行した結果、

ディレクトリ '/hoge/' 内のSSL証明書情報:
==================================================
処理中の証明書: hoge.example.com.crt.202505
  有効期限: Aug 28 23:17:58 2025 GMT
  ドメイン名: *.hoge.example.com
  発行局 (CN): E5
  発行局 (詳細): C=US, O=Let's Encrypt, CN=E5
  秘密鍵ファイル: hoge.example.com.202505
    鍵の状態: 証明書と一致 (EC)
--------------------------------------------------

と、

  • 有効期限
  • ドメイン名
  • 自己証明でないか(発行局から発行されているか)

が明示的に分かるようになりました。