Web Crypto API と @noble/curves でデジタル署名を検証する

この記事は CADDi Tech/Product Advent Calendar 2025 の8日目の記事です。

こんにちは。Control Plane部で認証周りの開発をしている宇都宮と申します。

キャディでは、メール送信基盤として SendGrid を利用しています。少し前に、SendGrid の生成するイベントデータを分析基盤に連携する仕組みを構築しました。その際に遭遇した、署名検証処理の実装において直面した課題と、それを解決するためのアプローチを紹介します。

Event Webhook 連携の流れ

SendGrid には、イベントを Webhook 連携する機能があります。この機能をベースに、以下のような仕組みを構築しました。

sequenceDiagram
  autonumber
  participant SG as SendGrid
  participant CW as Cloudflare Workers
  participant AP as 分析基盤

  Note over SG: イベント発生

  rect rgb(240, 248, 255)
    Note over SG: 署名生成 (ECDSA)<br/>Data = Timestamp + Payload
  end

  SG->>CW: HTTP POST (Webhook)<br/>Headers: Signature, Timestamp

  Note over CW: リクエスト受信

  rect rgb(255, 250, 240)
    Note over CW: 署名検証処理<br/>1. PubKey取得<br/>2. Data結合 (Timestamp + Payload)<br/>3. Verify(PubKey, Signature, Data)
  end

  alt 検証成功
    CW->>AP: イベントデータを送信
    CW-->>SG: 204 No Content
  else 検証失敗
    Note over CW: 不正なリクエストとして破棄
    CW-->>SG: 400 Bad Request
  end
  1. SendGrid が Webhook でイベントデータを送信する。この際、リクエストヘッダーには「タイムスタンプ」と「署名(タイムスタンプとペイロードを結合したものに対する署名)」が付与される。
  2. Cloudflare Workers でリクエストを受け付け、ヘッダーの署名を検証する。
  3. 署名の検証に成功したら、ペイロードをパースして分析基盤に連携する。

Webhook エンドポイントはインターネットに公開されるため、不正なリクエストが送られてくる可能性があります。そこで、SendGridが提供するデジタル署名の仕組みを使って、正規のリクエストであることを検証しています。

Cloudflare Workersの特徴と制約

連携の中核を担うのは Cloudflare Workers です。高速に起動するサーバレス環境で、CDNのエッジ上で動作するという特徴もあります。パフォーマンスとスケーラビリティに優れ、コスト面でも優秀です。

ただし、一つ注意すべき制約があります。それは、Cloudflare Workersで動作するのは独自のJavaScriptランタイムで、Node.jsではないという点です。 nodejs_compat というフラグを有効化することで互換モードにすることはできますが、サポートされていないAPIや言語機能があります。

SendGrid はWebhookの署名検証を行うライブラリを提供していますが、このライブラリが間接的に依存している js-sha256 の v0.9.0 は eval を使っていました。Cloudflare Workers のセキュリティモデルでは eval の実行が禁止されているため、SendGridの公式ライブラリを使うことはできませんでした。

一応、npm 等の overrides 機能を使うことで eval に依存しないバージョンに置き換えることは可能です。

    "overrides": {
      "js-sha256": "0.11.1"
    }

しかし、ライブラリの互換性の懸念からこの方法は避けました。

代替案: Web Crypto API

SendGrid の署名はドキュメントで説明されている通り、ECDSA(Elliptic Curve Digital Signature Algorithm, 楕円曲線デジタル署名アルゴリズム)を使っています。これは広く利用されているデジタル署名アルゴリズムなので、一般的な暗号ライブラリでも対応できるはずです。

そこで、Cloudflare Workersで利用可能なライブラリを調べたところ、Web Crypto APIが利用できることがわかりました。

実際のコードを見ていただいたほうが早いでしょう。Web Crypto API を用いた検証ロジックは以下のようになります。

interface VerifySendGridSignatureArgs {
  publicKey: string;
  payload: string;
  signature: string;
  timestamp: string;
}

export async function verifySendGridSignature({
  publicKey,
  payload,
  signature,
  timestamp,
}: VerifySendGridSignatureArgs): Promise<boolean> {
  try {
    // 公開鍵の読み込み
    const publicKeyBytes = base64ToBytes(publicKey);
    const cryptoKey = await crypto.subtle.importKey(
      'spki',
      publicKeyBytes,
      { name: 'ECDSA', namedCurve: 'P-256' },
      false, // 署名検証のみに使うので extractable は false でよい
      ['verify']
    );

    // 検証するデータをバイト列に変換
    const encoder = new TextEncoder();
    const data = encoder.encode(timestamp + payload);

    // 署名をバイト列に変換
    const signatureDer = base64ToBytes(signature);
    const signatureRaw = derSignatureToRaw(signatureDer); // この関数の実装は後述

    // 署名を検証
    return await crypto.subtle.verify(
      { name: 'ECDSA', hash: { name: 'SHA-256' } },
      cryptoKey,
      signatureRaw,
      data
    );
  } catch (error) {
    console.error('Signature verification failed with error:', error);
    return false;
  }
}

function base64ToBytes(base64: string) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

ぱっと見はこれで良さそうですが、実は厄介な点があります。SendGridの署名はDER形式になっていますが、Web Crypto APIの verify メソッドにはRAW形式の署名を渡す必要があります。そこで、DER形式の署名をRAW形式に変換する必要があります。この処理は以下のように実装できますが、コードは難解で、後で保守する際に困りそうです。

/**
 * !!!注意!!!
 * この関数はセキュリティ専門家による実装ではありません。
 * 本番環境では利用しないでください。
 *
 * DER 形式 (ASN.1) の署名を Raw 形式 (R|S) に変換する関数
 * ECDSA P-256 の場合、R と S はそれぞれ 32 バイトである必要がある。
 */
function derSignatureToRaw(derSignature: Uint8Array): Uint8Array {
  // DER 構造: 0x30 | 全長 | 0x02 | R長 | R | 0x02 | S長 | S
  let offset = 0;

  if (derSignature[offset++] !== 0x30) {
    throw new Error('Invalid DER signature: missing sequence tag');
  }

  // sequence length (skip)
  offset++;

  const extractInteger = (): Uint8Array => {
    if (derSignature[offset++] !== 0x02) {
      throw new Error('Invalid DER signature: missing integer tag');
    }
    const length = derSignature[offset++];
    const slice = derSignature.slice(offset, offset + length);
    offset += length;

    // DER 整数は符号付きなので、最上位ビットが1の場合、先頭に 0x00 が付くことがある。
    // Raw 形式 (32バイト固定) にするために調整する。

    // 1. 余分な 0x00 を取り除く (33バイトの場合など)
    let raw = slice;
    while (raw.length > 32 && raw[0] === 0) {
      raw = raw.slice(1);
    }

    // 2. 32バイト未満なら先頭を 0 で埋める
    if (raw.length < 32) {
      const padded = new Uint8Array(32);
      padded.set(raw, 32 - raw.length);
      return padded;
    }

    // 3. 32バイトちょうどならそのまま
    return raw;
  };

  const r = extractInteger();
  const s = extractInteger();

  // R と S を結合して返す
  const rawSignature = new Uint8Array(64);
  rawSignature.set(r, 0);
  rawSignature.set(s, 32);

  return rawSignature;
}

@noble/curves の採用

Web Crypto API を使うコードも動作はしますが、セキュリティや保守性の点で不安が残ります。そこで、Cloudflare Workers でも動作する、より高レベルなライブラリを探したところ、 @noble/curves を見つけました。

先ほどの derSignatureToRaw 関数は、 @noble/curves の v1 なら以下の1行で実装できます。

const signatureRaw = p256.Signature.fromBytes(signatureDer, 'der');

2025年8月にリリースされた v2 では署名の形式の変換も不要で、DER形式の署名をそのまま verify に渡すことができます。

import { p256 } from '@noble/curves/nist.js'; // v2.0.1

...

export async function verifySendGridSignature({
  publicKey,
  payload,
  signature,
  timestamp,
}: VerifySendGridSignatureArgs): Promise<boolean> {
  try {
    // 公開鍵の読み込み
    const publicKeySpki = base64ToBytes(publicKey);
    const publicKeyRaw = await p256SpkiToRaw(publicKeySpki); // この関数の実装は後述

    // 検証するデータをバイト列に変換
    const encoder = new TextEncoder();
    const data = encoder.encode(timestamp + payload);

    // 署名をバイト列に変換
    const signatureDer = base64ToBytes(signature);

    // 署名を検証
    return p256.verify(signatureDer, data, publicKeyRaw, {
      format: 'der', // DER形式だと明示すればRAWへの変換は不要
      lowS: false, // SendGrid の署名は Low-s 強制されていない
    });
  } catch (error) {
    console.error('Signature verification failed with error:', error);
    return false;
  }
}

v2 の変更点は他にもあり、メッセージをハッシュ化せずに渡せるようになったりと、より少ない手数で実装できるよう改善されています。一方で、lowSがデフォルトでtrue(主にブロックチェーン関係で利用する設定)になっており、この点は注意が必要です。

残念ながら、 @noble/curves は公開鍵のフォーマット変換には対応していません。そのため、ここだけWeb Crypto APIを使いました。

/**
 * P-256フォーマットのSPKI鍵をRAW形式の公開鍵に変換する
 */
async function p256SpkiToRaw(p256SpkiKey: Uint8Array): Promise<Uint8Array> {
  const cryptoKey = await crypto.subtle.importKey(
    'spki',
    p256SpkiKey,
    { name: 'ECDSA', namedCurve: 'P-256' },
    true, // export するので extractable は true にする必要がある
    [], // export するので usage は空でよい
  );
  const rawBuffer = await crypto.subtle.exportKey(
    'raw', // 'raw' の場合、 ArrayBuffer が返る
    cryptoKey,
  );
  if (!(rawBuffer instanceof ArrayBuffer)) {
    throw new Error('Expected rawBuffer to be an ArrayBuffer');
  }
  return new Uint8Array(rawBuffer);
}

なお、この関数は、実は Web Crypto API を使わずとも実装可能です。

/**
 * !!!注意!!!
 * この関数はセキュリティ専門家による実装ではありません。
 * エラーハンドリングを意図的に省略しています。
 * 本番環境では利用しないでください。
 */
function p256SpkiToRaw(p256SpkiKey: Uint8Array): Uint8Array {
  /**
   * P-256 の SPKI 形式 の構造:
   * [ASN.1 Header] + [0x04 (非圧縮マーカー)] + [X (32バイト)] + [Y (32バイト)]
   *
   * 非圧縮マーカー + Raw鍵 (X+Y) を取得する
   */
  return p256SpkiKey.slice(-65);
}

このコードは先ほどの derSignatureToRaw 関数に比べればシンプルではありますが、やはり暗号アルゴリズムが専門ではない開発者が保守するには不安があります。そこで、あえて Web Crypto API で変換処理を行う形にしました。

おわりに

本記事では、Cloudflare Workers環境でも動作する、Web Crypto APIと @noble/curves を利用した署名検証処理の実装例を紹介しました。

ここまで読んで、「Node.jsベースのサーバレスを使えばこんなに頑張る必要ないのでは?」と思う方もいるかもしれません。今回はワークロードの特性やデプロイの容易さなどの観点から Cloudflare Workers を使いましたが、AWS Lambda や Cloud Run functions といった、完全な Node.js ランタイムを持つサーバレス環境を選択するのも有力な解決策でしょう。