見出し画像

【立花証券e支店】PKI認証(簡易版)

はじめに

2026年5月30日
立花証券e支店の「パスキー認証」と共にAPI側では「PKI認証」が使えるようになったので対応してみました。(API v4r9)

PKI(公開鍵暗号基盤)認証

立花証券e支店へログインして手続き(操作)すると;
・認証ID (e_api_authid.txt)
・公開鍵/秘密鍵 (e_api_public_key.pem/e_api_private_key.pem)
がダウンロードできます。

画像
「お客様情報」>「e支店・API利用設定」

e支店APIでログイン時に使用するのは、「認証ID」と「秘密鍵」です。

サンプル(sample.py)

import base64
from datetime import datetime
import json
import logging
import threading
import time
from typing import Any

import requests

from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_OAEP
from Cryptodome.Hash import SHA256


class ApiClient:
    def __init__(self):
        self.logger = logging.getLogger(__class__.__name__)

        self.session = requests.Session()
        self.request_counter: int = 0
        self.session_info: dict = {}

        self._lock = threading.Lock()
        self.timeout = 60        


    def _get_next_p_no(self) -> str:
        """
        APIリクエストごとにインクリメントされるp_no(リクエスト番号)を生成します。
        """
        self.request_counter += 1
        return str(self.request_counter)

    def _get_current_datetime(self) -> str:
        """
        p_sd_date用の日時文字列(YYYY.MM.DD-HH:MI:SS.ms)を生成します。
        """
        # APIの仕様に合わせ、マイクロ秒を示す末尾3桁を削除しミリ秒までの精度にします。
        return datetime.now().strftime("%Y.%m.%d-%H:%M:%S.%f")[:-3]

    def _call_api(
        self,
        url: str,
        payload: dict,
        method: str = "POST",
        headers: dict[str, str] = {},
        timeout: int | None = None,
        stream: bool = False,
    ) -> Any:
        """
        APIサーバーへリクエストを送信し、結果を取得する共通メソッド。

        APIをコールし、取得したJSONの辞書オブジェクトを返します。
        このメソッド内で、リクエスト共通のパラメータが付与されます。

        Args:
            url (str): エンドポイントURL。
            payload (dict): 送信パラメータ。
            method (str): "GET" または "POST"。
            headers (dict[str, str]): HTTPヘッダー。
            timeout (int): タイムアウト秒数。
            stream (bool): ストリーミングモード。デフォルトは False です。

        Raises:
            requests.exceptions.RequestException: APIサーバーへ接続出来なかった場合。
            Exception: その他の例外発生時。

        Returns:
            dict: APIから取得したJSONを辞書へ変換したオブジェクト。
        """
        self.logger.debug("URL: %s", url)
        self.logger.debug("METHOD: %s", method)
        self.logger.debug("PAYLOAD: %s", json.dumps(payload))
        self.logger.debug("HEADERS: %s", json.dumps(headers))

        dict_result: dict = {}
        with self._lock:
            try:
                # 共通パラメーターの付与
                payload["p_no"] = self._get_next_p_no()
                payload["p_sd_date"] = self._get_current_datetime()
                payload["sJsonOfmt"] = "4"

                if timeout is None:
                    timeout = self.timeout

                response = self.session.request(
                    method=method,
                    url=url,
                    headers=headers,
                    json=payload,
                    timeout=timeout,
                )

                content_type = response.headers.get("Content-Type", "")
                self.logger.debug("CONTENT-TYPE: %s", content_type)

                response.raise_for_status()
                response.encoding = "cp932"
                self.logger.debug(
                    "STATUS: %s %s", response.status_code, response.reason
                )
                if stream is True:
                    return response

                if "application/json" in content_type:
                    dict_result = response.json()
                    return dict_result
                else:
                    self.logger.error("JSON形式の応答がありませんでした。")
                    self.logger.error(f"受信したテキスト: {response.text}")
                    return {"p_errno": "9999", "p_err": "No JSON"}

            except requests.exceptions.RequestException as exp:
                self.logger.error("URL: %s", exp.request.url if exp.request else url)
                self.logger.error(exp)
                if exp.response is not None:
                    self.logger.error(
                        "CODE: %s, REASON: %s",
                        exp.response.status_code,
                        exp.response.reason,
                    )
                    try:
                        self.logger.error("Message: %s", exp.response.json())
                    except json.JSONDecodeError:
                        self.logger.error("Message: %s", exp.response.text)
                raise exp

        return dict_result

    def login_pki(self, url: str, auth_id: str, private_pem: str) -> bool:
        """
        APIサーバーへログインします。

        成功した場合、セッション情報を`self.session_info`に格納します。

        Args:
            auth_id (str): ログインに使用する認証ID。
            private_pem (str): PEM形式の秘密鍵。

        Returns:
            bool: ログインに成功した場合はTrue、失敗した場合はFalse。
        """

        payload = {
            "sCLMID": "CLMAuthLoginRequest",
            "sAuthId": auth_id,
        }

        results = self._call_api(url, payload)

        self.session_info = results

        # 暗号化されている仮想URLを復号
        private_key = RSA.import_key(private_pem.encode())
        decryptor = PKCS1_OAEP.new(private_key, hashAlgo=SHA256)

        target_url_keys = ['sUrlRequest', 'sUrlMaster', 'sUrlPrice', 'sUrlEvent', 'sUrlEventWebSocket']
        for key in target_url_keys:
            url = self.session_info.get(key, "")

            self.logger.debug("復号前: %s", url)

            url = self.decrypt(url, decryptor)

            self.logger.debug("復号後: %s", url)

            self.session_info[key] = url.strip()

        return True

    def decrypt(self, b64_data: str, decryptor: PKCS1_OAEP.PKCS1OAEP_Cipher):
        """
        公開鍵で暗号化されたデータを復号します。

        Args:
            b64_data (str): 公開鍵で暗号化されたデータ(Base64形式でエンコードされた文字列)。
            decryptor (PKCS1_OAEP.PKCS1OAEP_Cipher): 復号器。

        Returns:
            str: 復号されたデータ。
        """
        encrypted_data = base64.b64decode(b64_data)
        return decryptor.decrypt(encrypted_data).decode()


    def logout(self) -> bool:
        """
        サーバーからログアウトし、セッション情報をクリアします。

        Returns:
            bool: ログアウトに成功した場合はTrue、失敗した場合はFalse。
        """
        payload = {
            "sCLMID": "CLMAuthLogoutRequest",
        }
        url = self.session_info.get("sUrlRequest")
        if url is None:
            return False
        
        results = self._call_api(url, payload)

        self.session_info = {}
        return True




if __name__ == "__main__":

    # logger の初期化
    logging.basicConfig(
        level=logging.DEBUG,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    )

    api_client = ApiClient()

    with open(".pki/e_api_authid.txt", "rb") as f:
        auth_id = f.read().decode()

    with open(".pki/e_api_private_key.pem", "rb") as f:
        private_pem = f.read().decode()

    url = "https://kabuka.e-shiten.jp/e_api_v4r9/auth/"

    is_login = api_client.login_pki(url, auth_id, private_pem)

    if is_login:
        time.sleep(10.0)
        api_client.logout()

簡単なサンプル作ってみました。(エラー処理等、カットしてます)
login_pki()とdecrypt()が重要ポイントです。

sample.py と同じ場所に .pki/ フォルダーを作成して、そこへ e支店からダウンロードした認証IDファイルと秘密鍵ファイルを置いて実行します。

※簡単にする為、テキストファイルをべた置きしてますが非推奨です。
※この方法を使う場合は .gitignore へ .pki/ を追記して下さい。
※現状は、まだ先に「電話番号認証」する必要あり(6/27迄?)

必要なライブラリは、requests と pycryptodomex です。
 ・uv add requests
 ・uv add pycryptodomex

<参考> pycryptodome と pycryptodomex の違い
基本的な中身は同じです。
import する時のパッケージ名が違うだけのようです。
pycryptodome は pycrypto の正統な後継者であり、互換性を持たせるために Crypto というパッケージ名になってます。
(ライブラリを入替えるだけでソース修正不要)
一方、pycryptodomex は Cryptodome というパッケージ名なので、一目で pycrypto ではなく pycryptodomex だとわかります。
紛らわしいので pycryptodomex がお勧めです。

解説

「認証ID」だけでログインのリクエスト自体は成功します。
結果として返される情報中の「仮想URL」が暗号化されているので、秘密鍵で復号する必要があります。
従って、秘密鍵のみならず、認証IDも絶対に漏洩することは許されません。

e支店のGitHubで提供されているサンプルは、この辺に気を使っていて認証IDと秘密鍵をまとめて暗号化して別ファイルにしています。
※それを復号するキーは環境変数へセットする前提

課題

現状、私はID/PWと同様に認証ID・秘密鍵もDBへ保存するようにしています。(テキストファイルのままでは置いていない)
ただ、これはGitHub等への誤漏洩を防止する程度の対処です。
e支店のサンプルは、その点、多少マシだと思いますが、そのまま真似するのも芸がない。

自分は Windows 前提なので Windows CryptoAPI でキーコンテナというものを使えば良いか?とか考えていますが、じゃ、認証IDは?とか・・・
セキュリティってキリがないですね・・・

以上、参考になったら「スキ」お願いします!


いいなと思ったら応援しよう!

ピックアップされています

Python アプリ開発

  • 10本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
IT企業を定年退職したオヤジが興味本位で競馬AI予想プログラムを作ってます。 JRA-VAN Data Lab.のデータをpython+lightGBMで分析。最終的には全自動で寝ててもPCが稼いでくれる。 そんなことを夢見てます。ちなみに競馬は素人です。
【立花証券e支店】PKI認証(簡易版)|HRAPS@競馬AI予想
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1