"""文字おこしくんの Webhook の署名を確かめる(Python 3.10+、標準ライブラリだけ)。 from mojiokoshi_webhook import verify_webhook, WebhookVerificationError event = verify_webhook(raw_body, request.headers, os.environ["MOJIOKOSHI_WEBHOOK_SECRET"]) export MOJIOKOSHI_WEBHOOK_SECRET=whsec_... # 管理画面が 1 回だけ表示したシークレット(サーバー側にだけ置く) python mojiokoshi_webhook.py 8787 # 試しに受け取る(届いた出来事を表示する。外からは HTTPS で届くようにする) 守っている決まり(Standard Webhooks): JSON として読む前の生の本文(bytes)で確かめる / webhook-timestamp が今の時刻から 5 分より離れていれば断る / webhook-signature を空白で区切り、v1 の署名のどれか 1 つが一致すれば通す(シークレットを作り直した 後の 24 時間は 2 つ並ぶ)/ 署名は hmac.compare_digest(定数時間)で比べる。同じ webhook-id の重複を捨てる仕事は、呼ぶ側に残す。 仕様: https://moji.kaitoranma.com/docs/webhooks.html """ import base64 import binascii import hashlib import hmac import json import os import sys import time from collections.abc import Iterable from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Protocol, cast WEBHOOK_TOLERANCE_SECONDS = 300 SECRET_PREFIX = "whsec_" class HeadersLike(Protocol): """ヘッダーの表(get と items を持つもの)。dict でも、Flask の request.headers でも、http.server の self.headers でもよい。""" def get(self, name: str, /) -> str | None: ... def items(self) -> Iterable[tuple[str, str]]: ... class WebhookVerificationError(Exception): """要求が文字おこしくんからのものと確かめられなかった(受け取る側は 400 などを返して捨てる)。""" def __init__(self, reason: str, message: str): super().__init__(message) self.reason = reason def _header(headers: HeadersLike, name: str) -> str | None: """ヘッダーの値。名前の大文字・小文字は区別しない(dict は区別して引くので、見つからなければ全部の名前を小文字にして比べる)。""" value = headers.get(name) if value is not None: return value for key, candidate in headers.items(): if key.lower() == name: return candidate return None def _signing_key(secret: str) -> bytes: if not secret.startswith(SECRET_PREFIX): raise ValueError("the webhook secret must be whsec_ followed by base64 (copy it from the dashboard)") try: key = base64.b64decode(secret[len(SECRET_PREFIX):], validate=True) except binascii.Error as error: raise ValueError("the webhook secret is not valid base64") from error if not key: raise ValueError("the webhook secret is empty") return key def verify_webhook( body: bytes, headers: HeadersLike, secret: str, tolerance_seconds: int = WEBHOOK_TOLERANCE_SECONDS, now: float | None = None, ) -> dict[str, Any]: """署名と時刻を確かめ、本文を読んだ出来事(dict)を返す。確かめられなければ WebhookVerificationError を投げる。 body には受け取った生の本文(bytes)を渡す。JSON として読んでから書き直したものでは、署名が合わない。 secret の形が違う(whsec_ でない)ときは、設定の誤りなので ValueError を投げる。 """ webhook_id = _header(headers, "webhook-id") timestamp = _header(headers, "webhook-timestamp") signatures = _header(headers, "webhook-signature") if webhook_id is None or timestamp is None or signatures is None: raise WebhookVerificationError("missing_headers", "webhook-id, webhook-timestamp and webhook-signature are required") if not (timestamp.isascii() and timestamp.isdigit()) or len(timestamp) > 15: raise WebhookVerificationError("invalid_timestamp", "webhook-timestamp is not a Unix time in seconds") current = time.time() if now is None else now # 古い要求を後からもう一度送られても受け付けないように、時刻が離れすぎたものを断る(先の時刻も断る)。 if abs(current - int(timestamp)) > tolerance_seconds: raise WebhookVerificationError("timestamp_out_of_range", "webhook-timestamp is too far from the current time") signed = f"{webhook_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(_signing_key(secret), signed, hashlib.sha256).digest()).decode() matched = False for candidate in signatures.split(" "): version, _, value = candidate.partition(",") # v1 だけを見る(文字おこしくんがほかの版の署名を足しても、v1 の署名で確かめられる)。 if version == "v1" and hmac.compare_digest(value.encode(), expected.encode()): matched = True if not matched: raise WebhookVerificationError("invalid_signature", "no webhook-signature matched the secret") try: parsed: object = json.loads(body) except ValueError as error: raise WebhookVerificationError("invalid_body", "the body is not JSON") from error if not isinstance(parsed, dict): raise WebhookVerificationError("invalid_body", "the body is not a webhook event") # JSON のオブジェクトの名前はいつも文字列なので、dict[str, Any] として扱える。 event = cast("dict[str, Any]", parsed) if not isinstance(event.get("type"), str): raise WebhookVerificationError("invalid_body", "the body is not a webhook event") return event class _Receiver(BaseHTTPRequestHandler): """試しに受け取るための受け取り口。届いた出来事を表示して 204 を返す(本番では DB やキューに入れる)。""" seen: set[str] = set() def do_POST(self) -> None: # noqa: N802(http.server の決まった名前) body = self.rfile.read(int(self.headers.get("content-length") or 0)) try: event = verify_webhook(body, self.headers, os.environ["MOJIOKOSHI_WEBHOOK_SECRET"]) except WebhookVerificationError as error: self.send_response(400) self.end_headers() print("rejected:", error.reason, file=sys.stderr) return # 先に 2xx を返す(文字おこしくんは 15 秒で打ち切り、失敗として送り直す)。 self.send_response(204) self.end_headers() # 同じ出来事が 2 回以上届くことがあるので、id(webhook-id)で重複を捨てる。 if event["id"] in self.seen: return self.seen.add(event["id"]) if event["type"] == "session.started": print("started:", event["data"]["sessionId"], event["data"]["label"]) elif event["type"] == "session.ended": print("ended:", event["data"]["sessionId"], event["data"]["endReason"], event["data"]["audioSeconds"], "s") elif event["type"] == "webhook.test": print("test:", event["data"]["webhookId"]) # 知らない type は何もしない(文字おこしくんは出来事の種類を足すことがある)。 if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787 print(f"listening on http://127.0.0.1:{port}/ (Ctrl+C で止める)") ThreadingHTTPServer(("127.0.0.1", port), _Receiver).serve_forever()