/** * 文字おこしくんの Webhook の署名を確かめる(依存なし・1 ファイル)。 * * Node 22.18 以降(型を取り除いてそのまま実行できる)/ Deno / Bun / Cloudflare Workers / tsx で動く(Web Crypto を使う)。 * * const event = await verifyMojiokoshiWebhook(rawBody, request.headers, process.env.MOJIOKOSHI_WEBHOOK_SECRET!); * * 守っている決まり(Standard Webhooks): JSON として読む前の生の本文で確かめる / webhook-timestamp が今の時刻から 5 分より * 離れていれば断る / webhook-signature を空白で区切り、v1 の署名のどれか 1 つが一致すれば通す(シークレットを作り直した後の * 24 時間は 2 つ並ぶ)/ 署名は定数時間で比べる。同じ webhook-id の重複を捨てる仕事は、呼ぶ側に残す(覚えておく場所が要る)。 * 仕様: https://moji.kaitoranma.com/docs/webhooks.html */ /** webhook-timestamp と今の時刻の、許すずれ(秒)。 */ export const WEBHOOK_TOLERANCE_SECONDS = 300; const SECRET_PREFIX = "whsec_"; /** fetch の Headers か、Node の request.headers(IncomingHttpHeaders)のような名前 → 値の表。 */ export type HeadersLike = | { get(name: string): string | null } | Readonly>; export interface VerifyMojiokoshiWebhookOptions { /** 既定 WEBHOOK_TOLERANCE_SECONDS。 */ readonly toleranceSeconds?: number; /** 今の時刻(Unix 秒)。テスト用で、既定は時計の今の時刻。 */ readonly nowSeconds?: number; } /** 要求が文字おこしくんからのものと確かめられなかった理由。受け取る側は 400 などを返して捨てる。 */ export type MojiokoshiWebhookErrorReason = "missing_headers" | "invalid_timestamp" | "timestamp_out_of_range" | "invalid_signature" | "invalid_body"; export class MojiokoshiWebhookError extends Error { readonly reason: MojiokoshiWebhookErrorReason; constructor(reason: MojiokoshiWebhookErrorReason, message: string) { super(message); this.name = "MojiokoshiWebhookError"; this.reason = reason; } } export interface SessionStartedData { readonly sessionId: string; readonly tenantId: string; readonly label: string; /** 入力元での識別子(公式の Discord ボットなら discord::)。無いこともある。 */ readonly externalRef?: string; /** エポックミリ秒。 */ readonly createdAt: number; } export interface SessionEndedData extends SessionStartedData { readonly endedAt: number; /** requested / idle / quota_exceeded。文字おこしくんは理由を足すことがある。 */ readonly endReason: string; /** 利用量(音声認識にかけた音声の秒数)。 */ readonly audioSeconds: number; /** 記録が削除される時刻。記録を削除しないなら無い。 */ readonly retainUntil?: number; } export interface WebhookTestData { readonly webhookId: string; readonly tenantId: string; } interface Envelope { /** 出来事の ID(webhook-id と同じ)。重複を捨てるのに使う。 */ readonly id: string; readonly type: T; /** 出来事の時刻(エポックミリ秒)。 */ readonly createdAt: number; readonly data: D; } /** * 文字おこしくんが送る出来事。文字おこしくんは出来事の種類を足すことがあるので、switch の default では何もせずに 2xx を返す * (知らない type を失敗にすると、文字おこしくんが送り直し続け、最後には送信先を止める)。 */ export type MojiokoshiWebhookEvent = | Envelope<"session.started", SessionStartedData> | Envelope<"session.ended", SessionEndedData> | Envelope<"webhook.test", WebhookTestData>; const encoder = new TextEncoder(); /** * 署名と時刻を確かめ、本文を読んだ出来事を返す。確かめられなければ MojiokoshiWebhookError を投げる。 * secret の形が違う(whsec_ でない)ときは、設定の誤りなので TypeError を投げる。 * * @param body 受け取った生の本文(文字列かバイト列)。JSON として読んでから書き直したものでは、署名が合わない。 */ export async function verifyMojiokoshiWebhook( body: string | Uint8Array, headers: HeadersLike, secret: string, options: VerifyMojiokoshiWebhookOptions = {}, ): Promise { const id = headerValue(headers, "webhook-id"); const timestamp = headerValue(headers, "webhook-timestamp"); const signatures = headerValue(headers, "webhook-signature"); if (id === undefined || timestamp === undefined || signatures === undefined) { throw new MojiokoshiWebhookError("missing_headers", "webhook-id, webhook-timestamp and webhook-signature are required"); } if (!/^\d{1,15}$/.test(timestamp)) throw new MojiokoshiWebhookError("invalid_timestamp", "webhook-timestamp is not a Unix time in seconds"); const now = options.nowSeconds ?? Math.floor(Date.now() / 1000); // 古い要求を後からもう一度送られても受け付けないように、時刻が離れすぎたものを断る(先の時刻も断る)。 if (Math.abs(now - Number(timestamp)) > (options.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS)) { throw new MojiokoshiWebhookError("timestamp_out_of_range", "webhook-timestamp is too far from the current time"); } const bytes = typeof body === "string" ? encoder.encode(body) : body; const prefix = encoder.encode(`${id}.${timestamp}.`); const signed = new Uint8Array(prefix.length + bytes.length); signed.set(prefix, 0); signed.set(bytes, prefix.length); const key = await crypto.subtle.importKey("raw", signingKey(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const expected = new Uint8Array(await crypto.subtle.sign("HMAC", key, signed)); let matched = false; for (const candidate of signatures.split(" ")) { const comma = candidate.indexOf(","); // v1 だけを見る(文字おこしくんがほかの版の署名を足しても、v1 の署名で確かめられる)。 if (comma < 0 || candidate.slice(0, comma) !== "v1") continue; const actual = decodeBase64(candidate.slice(comma + 1)); if (actual !== undefined && constantTimeEqual(actual, expected)) matched = true; } if (!matched) throw new MojiokoshiWebhookError("invalid_signature", "no webhook-signature matched the secret"); let event: unknown; try { event = JSON.parse(typeof body === "string" ? body : new TextDecoder().decode(body)); } catch { throw new MojiokoshiWebhookError("invalid_body", "the body is not JSON"); } if (typeof event !== "object" || event === null || typeof (event as { type?: unknown }).type !== "string") { throw new MojiokoshiWebhookError("invalid_body", "the body is not a webhook event"); } return event as MojiokoshiWebhookEvent; } /** * シークレット(whsec_ + base64)から HMAC の鍵のバイト列を取り出す。TypeScript の lib.dom の Web Crypto の型は * ArrayBuffer の上のバイト列だけを受ける(Uint8Array の型は SharedArrayBuffer の上のものも含む)ので、新しい ArrayBuffer に写して返す。 */ function signingKey(secret: string): ArrayBuffer { const decoded = secret.startsWith(SECRET_PREFIX) ? decodeBase64(secret.slice(SECRET_PREFIX.length)) : undefined; if (decoded === undefined || decoded.length === 0) throw new TypeError("the webhook secret must be whsec_ followed by base64 (copy it from the dashboard)"); const key = new ArrayBuffer(decoded.length); new Uint8Array(key).set(decoded); return key; } function decodeBase64(text: string): Uint8Array | undefined { if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return undefined; try { return Uint8Array.from(atob(text), (char) => char.charCodeAt(0)); } catch { return undefined; } } /** 長さが同じなら、どこで違っても同じ手間で比べる(一致した長さから署名を推測させない)。長さは秘密ではない。 */ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let difference = 0; for (let index = 0; index < a.length; index++) difference |= (a[index] ?? 0) ^ (b[index] ?? 0); return difference === 0; } /** ヘッダーの値。名前の大文字・小文字は区別しない。同じ名前が複数あるときは、確かめられないので無いものとする。 */ function headerValue(headers: HeadersLike, name: string): string | undefined { if (typeof headers.get === "function") return (headers as { get(name: string): string | null }).get(name) ?? undefined; const record = headers as Readonly>; for (const [key, value] of Object.entries(record)) { if (key.toLowerCase() !== name) continue; if (typeof value === "string") return value; return value !== undefined && value.length === 1 ? value[0] : undefined; } return undefined; }