T-CREATOR

JavaScript Web Crypto 実務入門:署名・ハッシュ・暗号化の使い分け

JavaScript Web Crypto 実務入門:署名・ハッシュ・暗号化の使い分け

Web 開発において、セキュリティは避けて通れない重要なテーマです。特にブラウザ上でデータを扱う際、「このデータは改ざんされていないか?」「機密情報を安全に保管できているか?」といった疑問に直面することがありますよね。

JavaScript の Web Crypto API は、こうしたセキュリティニーズに応えるための強力なツールを提供しています。しかし、署名・ハッシュ・暗号化という 3 つの技術は、それぞれ異なる目的と用途を持っており、適切に使い分けることが重要です。本記事では、実務で迷わないための使い分けのポイントと具体的な実装方法を、初心者の方にもわかりやすく解説していきます。

背景

Web Crypto API とは

Web Crypto API は、W3C によって標準化されたブラウザネイティブの暗号化 API です。従来、JavaScript で暗号化処理を行う際は外部ライブラリに頼る必要がありましたが、現在ではモダンブラウザに標準で組み込まれており、安全で高速な暗号化処理が可能になりました。

この API はwindow.crypto.subtleオブジェクトを通じて利用でき、ハッシュ生成、デジタル署名、データの暗号化・復号化など、さまざまな暗号化操作をサポートしています。

3 つの暗号技術の役割

セキュリティを実現するために、Web Crypto API では主に 3 つの技術が用意されています。それぞれの技術には明確な目的があり、適切に使い分けることで堅牢なセキュリティを構築できるのです。

以下の図は、3 つの暗号技術がどのような役割を担っているかを示しています。

mermaidflowchart TB
    data["データ保護の<br/>ニーズ"] --> question{"何を<br/>守りたい?"}
    question -->|"データの真正性<br/>改ざん検知"| hash["ハッシュ"]
    question -->|"送信者の証明<br/>否認防止"| sign["デジタル署名"]
    question -->|"データの機密性<br/>第三者から隠す"| encrypt["暗号化"]

    hash --> hashUse["パスワード保存<br/>データ整合性確認<br/>ファイル検証"]
    sign --> signUse["API認証<br/>ドキュメント署名<br/>トークン検証"]
    encrypt --> encryptUse["個人情報保護<br/>通信の暗号化<br/>機密データ保存"]

この図からわかるように、守りたい内容によって使う技術が変わります。データの真正性を確認したいならハッシュ、送信者を証明したいなら署名、データ自体を隠したいなら暗号化という具合です。

課題

開発現場でよくある混同

実務において、これら 3 つの技術を混同してしまうケースが少なくありません。例えば以下のような誤った選択をしてしまうことがあります。

#誤った選択例問題点正しい選択
1パスワードをハッシュ化して送信通信路で盗聴される可能性HTTPS 通信 + サーバー側でハッシュ化
2データの真正性確認に暗号化を使用暗号化しても改ざんは検知できないハッシュまたはデジタル署名を使用
3機密データ保護にハッシュを使用ハッシュは元に戻せないため不適切暗号化を使用
4API 認証にハッシュのみ使用リプレイアタックに脆弱HMAC 署名 + タイムスタンプ

技術選択における判断基準の不明確さ

多くの開発者が直面する問題は、「どの場面でどの技術を使うべきか」という判断基準が曖昧なことです。セキュリティ要件は案件ごとに異なり、教科書的な知識だけでは実務での判断が難しいのが実情でしょう。

特に以下のようなシーンで迷いが生じやすくなっています。

  • フロントエンドでトークンを保存する際の保護方法
  • ファイルアップロード時の整合性チェック方法
  • API 通信における認証・認可の実装方法
  • ローカルストレージに保存するデータの保護方法

これらの課題を解決するには、各技術の特性と使い分けの原則を理解することが不可欠です。

解決策

使い分けの基本原則

3 つの暗号技術を正しく使い分けるには、以下の原則を押さえておくと良いでしょう。

ハッシュを使うべき場面

ハッシュは、データから固定長の「指紋」を生成する一方向関数です。元のデータに戻すことはできませんが、同じデータからは必ず同じハッシュ値が生成されます。

適切な用途

  • パスワードの保存(サーバー側)
  • データの整合性確認(ファイルが改ざんされていないか)
  • データの一意性確認(重複チェック)
  • キャッシュキーの生成

デジタル署名を使うべき場面

デジタル署名は、秘密鍵でデータに署名し、公開鍵で検証することで、送信者の真正性とデータの完全性を同時に保証します。

適切な用途

  • API 認証(JWT、OAuth)
  • データ送信者の証明
  • トランザクションの否認防止
  • ドキュメントやコードの署名

暗号化を使うべき場面

暗号化は、データを秘密の鍵を使って読めない形に変換し、同じ鍵(または対応する鍵)でのみ元に戻せるようにします。

適切な用途

  • 機密情報の保存(個人情報、クレジットカード情報)
  • 通信内容の秘匿(HTTPS 内でもさらに暗号化が必要な場合)
  • ファイルの暗号化
  • セッショントークンの保護

以下の図は、実際のユースケースからどの技術を選択すべきかの判断フローを示しています。

mermaidflowchart TD
    start["セキュリティ<br/>要件の確認"] --> q1{"データを<br/>元に戻す<br/>必要がある?"}

    q1 -->|"YES<br/>復号が必要"| encrypt_choice["暗号化を選択"]
    q1 -->|"NO<br/>一方向でOK"| q2{"送信者の<br/>証明が<br/>必要?"}

    q2 -->|"YES<br/>証明が必要"| sign_choice["デジタル署名<br/>を選択"]
    q2 -->|"NO<br/>データ検証のみ"| hash_choice["ハッシュを選択"]

    encrypt_choice --> encrypt_type{"鍵の共有<br/>方法は?"}
    encrypt_type -->|"同じ鍵で<br/>暗号化・復号"| aes["AES-GCM<br/>対称暗号"]
    encrypt_type -->|"公開鍵で暗号化<br/>秘密鍵で復号"| rsa["RSA-OAEP<br/>非対称暗号"]

    sign_choice --> sign_type{"鍵の種類は?"}
    sign_type -->|"共有秘密鍵"| hmac["HMAC<br/>共通鍵署名"]
    sign_type -->|"公開鍵方式"| ecdsa["ECDSA/RSA<br/>公開鍵署名"]

    hash_choice --> hash_type["SHA-256/SHA-384<br/>SHA-512"]

この判断フローに従うことで、状況に応じた適切な技術選択が可能になります。

アルゴリズムの選択指針

各技術において、複数のアルゴリズムが用意されていますが、2025 年時点での推奨は以下の通りです。

#用途推奨アルゴリズム理由
1ハッシュ生成SHA-256, SHA-384, SHA-512セキュリティと速度のバランスが良い
2デジタル署名ECDSA (P-256, P-384)RSA より鍵サイズが小さく高速
3対称鍵暗号化AES-GCM (256bit)認証付き暗号化で改ざん検知も可能
4非対称鍵暗号化RSA-OAEP (2048bit 以上)広くサポートされ互換性が高い
5メッセージ認証HMAC-SHA256API トークン生成に最適

古いアルゴリズム(MD5、SHA-1、DES 等)は脆弱性が発見されているため、使用を避けるべきです。

具体例

ここからは、実際のコードを通じて各技術の実装方法を見ていきましょう。すべてのコードは TypeScript で記述しており、実務でそのまま活用できる形になっています。

ハッシュの実装

ハッシュは、データの整合性確認やパスワード検証などに使用します。ファイルの SHA-256 ハッシュを生成する例を見てみましょう。

データ準備とハッシュ生成の基本

まず、テキストデータをハッシュ化する基本的な実装です。

typescript/**
 * テキストデータのSHA-256ハッシュを生成
 * @param message - ハッシュ化するテキスト
 * @returns ハッシュ値(16進数文字列)
 */
async function generateHash(
  message: string
): Promise<string> {
  // テキストをUint8Arrayに変換
  const encoder = new TextEncoder();
  const data = encoder.encode(message);

  // SHA-256ハッシュを生成
  const hashBuffer = await crypto.subtle.digest(
    'SHA-256',
    data
  );

  // ArrayBufferを16進数文字列に変換
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');

  return hashHex;
}

このコードでは、まずテキストをバイト配列に変換し、crypto.subtle.digest()でハッシュを生成しています。結果は 16 進数文字列として返されるため、比較や保存が容易です。

ファイルの整合性チェック

実務でよくある用途として、アップロードされたファイルの整合性確認があります。

typescript/**
 * ファイルのハッシュ値を計算して整合性を確認
 * @param file - 検証するファイル
 * @param expectedHash - 期待されるハッシュ値
 * @returns 整合性チェックの結果
 */
async function verifyFileIntegrity(
  file: File,
  expectedHash: string
): Promise<boolean> {
  // ファイルをArrayBufferとして読み込み
  const arrayBuffer = await file.arrayBuffer();

  // SHA-256ハッシュを生成
  const hashBuffer = await crypto.subtle.digest(
    'SHA-256',
    arrayBuffer
  );

  // 16進数文字列に変換
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const actualHash = hashArray
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');

  // 期待値と比較
  return actualHash === expectedHash;
}

このようにファイルのハッシュ値を計算し、サーバーから受け取った期待値と比較することで、ダウンロード中にファイルが改ざんされていないか確認できます。

使用例

typescript// テキストのハッシュ生成
const hash = await generateHash('Hello, World!');
console.log('Hash:', hash);
// 出力例: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f

// ファイルの整合性確認
const fileInput =
  document.querySelector<HTMLInputElement>('#fileInput');
if (fileInput?.files?.[0]) {
  const isValid = await verifyFileIntegrity(
    fileInput.files[0],
    'expected_hash_value_here'
  );
  console.log('ファイルは有効:', isValid);
}

ハッシュは同じ入力から必ず同じ出力が得られるため、データの同一性確認に最適ですね。

デジタル署名の実装

デジタル署名は、データの送信者が本物であることを証明し、データが改ざんされていないことを保証します。ECDSA(楕円曲線デジタル署名アルゴリズム)を使った実装を見ていきましょう。

鍵ペアの生成

まず、署名に使用する秘密鍵と検証用の公開鍵を生成します。

typescript/**
 * ECDSA用の鍵ペアを生成
 * @returns 秘密鍵と公開鍵のペア
 */
async function generateKeyPair(): Promise<CryptoKeyPair> {
  const keyPair = await crypto.subtle.generateKey(
    {
      name: 'ECDSA',
      namedCurve: 'P-256', // P-256, P-384, P-521から選択
    },
    true, // 鍵のエクスポートを許可
    ['sign', 'verify'] // 用途を指定
  );

  return keyPair;
}

P-256 曲線は、セキュリティと性能のバランスが良く、広く使用されています。より高いセキュリティが必要な場合は P-384 や P-521 を選択できます。

データへの署名

生成した秘密鍵を使ってデータに署名を付与します。

typescript/**
 * データにデジタル署名を付与
 * @param privateKey - 署名用の秘密鍵
 * @param message - 署名するメッセージ
 * @returns 署名データ(Base64エンコード)
 */
async function signData(
  privateKey: CryptoKey,
  message: string
): Promise<string> {
  // メッセージをバイト配列に変換
  const encoder = new TextEncoder();
  const data = encoder.encode(message);

  // 署名を生成
  const signature = await crypto.subtle.sign(
    {
      name: 'ECDSA',
      hash: { name: 'SHA-256' }, // ハッシュアルゴリズムを指定
    },
    privateKey,
    data
  );

  // Base64エンコードして返す
  return arrayBufferToBase64(signature);
}

署名は秘密鍵でのみ生成できるため、署名が有効であれば送信者の真正性が保証されます。

署名の検証

受け取った署名を公開鍵で検証します。

typescript/**
 * デジタル署名を検証
 * @param publicKey - 検証用の公開鍵
 * @param message - 元のメッセージ
 * @param signatureBase64 - 検証する署名(Base64)
 * @returns 署名が有効かどうか
 */
async function verifySignature(
  publicKey: CryptoKey,
  message: string,
  signatureBase64: string
): Promise<boolean> {
  // メッセージをバイト配列に変換
  const encoder = new TextEncoder();
  const data = encoder.encode(message);

  // 署名をBase64からArrayBufferに変換
  const signature = base64ToArrayBuffer(signatureBase64);

  // 署名を検証
  const isValid = await crypto.subtle.verify(
    {
      name: 'ECDSA',
      hash: { name: 'SHA-256' },
    },
    publicKey,
    signature,
    data
  );

  return isValid;
}

検証が成功すれば、メッセージが改ざんされておらず、正しい秘密鍵の所有者が署名したことが証明されます。

ユーティリティ関数

Base64 エンコード・デコード用のヘルパー関数です。

typescript/**
 * ArrayBufferをBase64文字列に変換
 */
function arrayBufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

/**
 * Base64文字列をArrayBufferに変換
 */
function base64ToArrayBuffer(base64: string): ArrayBuffer {
  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.buffer;
}

使用例

typescript// 鍵ペアを生成
const keyPair = await generateKeyPair();

// データに署名
const message = 'Important message';
const signature = await signData(
  keyPair.privateKey,
  message
);
console.log('署名:', signature);

// 署名を検証
const isValid = await verifySignature(
  keyPair.publicKey,
  message,
  signature
);
console.log('署名は有効:', isValid); // true

// 改ざんされたメッセージで検証
const isValidTampered = await verifySignature(
  keyPair.publicKey,
  'Tampered message',
  signature
);
console.log('改ざん後の検証:', isValidTampered); // false

このように、デジタル署名を使うことでメッセージの真正性と完全性を同時に保証できるのです。

暗号化の実装

暗号化は、データを第三者から隠すために使用します。AES-GCM という認証付き暗号化方式を使った実装を見ていきましょう。

暗号化鍵の生成

まず、データの暗号化・復号化に使用する共通鍵を生成します。

typescript/**
 * AES-GCM用の暗号化鍵を生成
 * @returns 256bitのAES鍵
 */
async function generateEncryptionKey(): Promise<CryptoKey> {
  const key = await crypto.subtle.generateKey(
    {
      name: 'AES-GCM',
      length: 256, // 128, 192, 256から選択
    },
    true, // 鍵のエクスポートを許可
    ['encrypt', 'decrypt'] // 用途を指定
  );

  return key;
}

AES-GCM は認証付き暗号化方式で、データの暗号化と改ざん検知を同時に行えるため、現代の Web アプリケーションで推奨されています。

データの暗号化

生成した鍵を使ってデータを暗号化します。

typescript/**
 * データを暗号化
 * @param key - 暗号化鍵
 * @param plaintext - 平文データ
 * @returns 暗号化データとIV(初期化ベクトル)
 */
async function encryptData(
  key: CryptoKey,
  plaintext: string
): Promise<{ ciphertext: string; iv: string }> {
  // ランダムなIV(初期化ベクトル)を生成
  // IVは毎回異なる値を使用する必要がある
  const iv = crypto.getRandomValues(new Uint8Array(12)); // GCMモードでは12バイト推奨

  // 平文をバイト配列に変換
  const encoder = new TextEncoder();
  const data = encoder.encode(plaintext);

  // データを暗号化
  const ciphertext = await crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv: iv,
    },
    key,
    data
  );

  // Base64エンコードして返す
  return {
    ciphertext: arrayBufferToBase64(ciphertext),
    iv: arrayBufferToBase64(iv.buffer),
  };
}

IV は暗号化のたびに異なる値を使用することが重要です。同じ鍵と IV の組み合わせで複数のデータを暗号化すると、セキュリティが低下してしまいます。

データの復号化

暗号化されたデータを元に戻します。

typescript/**
 * データを復号化
 * @param key - 復号化鍵
 * @param ciphertext - 暗号化データ(Base64)
 * @param iv - IV(Base64)
 * @returns 復号化された平文
 */
async function decryptData(
  key: CryptoKey,
  ciphertext: string,
  iv: string
): Promise<string> {
  // Base64からArrayBufferに変換
  const ciphertextBuffer = base64ToArrayBuffer(ciphertext);
  const ivBuffer = base64ToArrayBuffer(iv);

  try {
    // データを復号化
    const decrypted = await crypto.subtle.decrypt(
      {
        name: 'AES-GCM',
        iv: new Uint8Array(ivBuffer),
      },
      key,
      ciphertextBuffer
    );

    // バイト配列をテキストに変換
    const decoder = new TextDecoder();
    return decoder.decode(decrypted);
  } catch (error) {
    // 復号化失敗(改ざんされている可能性)
    throw new Error(
      '復号化に失敗しました。データが改ざんされている可能性があります。'
    );
  }
}

AES-GCM は認証付き暗号化なので、データが改ざんされていた場合、復号化時に自動的にエラーが発生します。これにより、暗号化と改ざん検知を同時に実現できるわけです。

使用例

typescript// 暗号化鍵を生成
const encryptionKey = await generateEncryptionKey();

// 機密データを暗号化
const secretData =
  'My credit card number: 1234-5678-9012-3456';
const encrypted = await encryptData(
  encryptionKey,
  secretData
);
console.log('暗号文:', encrypted.ciphertext);
console.log('IV:', encrypted.iv);

// データを復号化
const decrypted = await decryptData(
  encryptionKey,
  encrypted.ciphertext,
  encrypted.iv
);
console.log('復号化:', decrypted); // 元の平文が復元される

// 改ざんされたデータの復号化を試みる
try {
  const tampered =
    encrypted.ciphertext.slice(0, -10) + 'XXXXXXXXXX';
  await decryptData(encryptionKey, tampered, encrypted.iv);
} catch (error) {
  console.error('エラー:', error.message);
  // 出力: "復号化に失敗しました。データが改ざんされている可能性があります。"
}

このように、AES-GCM を使えば暗号化と改ざん検知を一度に行えるため、実務では非常に便利です。

実践的な組み合わせ例

実際の開発では、これら 3 つの技術を組み合わせて使用することが多くあります。例えば、セキュアなメッセージ交換システムでは以下のような構成が考えられます。

mermaidsequenceDiagram
    participant Alice as 送信者(Alice)
    participant Server as サーバー
    participant Bob as 受信者(Bob)

    Note over Alice: 1. メッセージ作成
    Alice->>Alice: 平文メッセージを準備

    Note over Alice: 2. 暗号化
    Alice->>Alice: Bobの公開鍵で暗号化<br/>(RSA-OAEP)

    Note over Alice: 3. 署名
    Alice->>Alice: 暗号文のハッシュを<br/>自分の秘密鍵で署名<br/>(ECDSA)

    Alice->>Server: 暗号文 + 署名を送信
    Server->>Bob: 暗号文 + 署名を転送

    Note over Bob: 4. 署名検証
    Bob->>Bob: Aliceの公開鍵で<br/>署名を検証

    Note over Bob: 5. 復号化
    Bob->>Bob: 自分の秘密鍵で復号化<br/>(RSA-OAEP)

    Note over Bob: メッセージ取得完了

この図が示すように、暗号化でメッセージの機密性を保ち、署名で送信者の真正性を保証する、という組み合わせが実現できます。

API 認証での使用例

JWT(JSON Web Token)のような仕組みでは、署名とハッシュを組み合わせて使用します。

typescript/**
 * 簡易的なJWTライクなトークン生成
 * @param payload - トークンに含めるデータ
 * @param secretKey - 署名用の秘密鍵
 * @returns トークン文字列
 */
async function createToken(
  payload: object,
  secretKey: CryptoKey
): Promise<string> {
  // ヘッダーとペイロードを作成
  const header = { alg: 'HS256', typ: 'JWT' };
  const encodedHeader = btoa(JSON.stringify(header));
  const encodedPayload = btoa(JSON.stringify(payload));

  // 署名対象データ
  const data = `${encodedHeader}.${encodedPayload}`;

  // HMAC-SHA256で署名
  const encoder = new TextEncoder();
  const signature = await crypto.subtle.sign(
    'HMAC',
    secretKey,
    encoder.encode(data)
  );

  const encodedSignature = arrayBufferToBase64(signature);

  // トークンを組み立て
  return `${data}.${encodedSignature}`;
}

このように、実務では複数の暗号技術を適切に組み合わせることで、堅牢なセキュリティを実現できます。

エラーハンドリングとセキュリティ考慮事項

実装時には、適切なエラー処理とセキュリティ対策が必要です。

typescript/**
 * 安全な暗号化処理のラッパー
 */
class SecureCrypto {
  /**
   * 安全なハッシュ生成
   */
  static async hash(data: string): Promise<string> {
    if (!data || data.length === 0) {
      throw new Error('ハッシュ化するデータが空です');
    }

    try {
      return await generateHash(data);
    } catch (error) {
      console.error('ハッシュ生成エラー:', error);
      throw new Error('ハッシュの生成に失敗しました');
    }
  }

  /**
   * 安全な暗号化
   */
  static async encrypt(
    key: CryptoKey,
    plaintext: string
  ): Promise<{ ciphertext: string; iv: string }> {
    if (!plaintext || plaintext.length === 0) {
      throw new Error('暗号化するデータが空です');
    }

    if (!key || key.type !== 'secret') {
      throw new Error('無効な暗号化鍵です');
    }

    try {
      return await encryptData(key, plaintext);
    } catch (error) {
      console.error('暗号化エラー:', error);
      throw new Error('データの暗号化に失敗しました');
    }
  }
}

重要なセキュリティ考慮事項

#項目注意点
1鍵の保管秘密鍵は絶対にクライアント側に平文で保存しない
2IV の再利用同じ鍵と IV の組み合わせを複数回使用しない
3エラーメッセージ詳細な失敗理由を公開せず、汎用的なメッセージを返す
4タイミング攻撃署名検証では定数時間比較を使用する
5ブラウザ対応古いブラウザではポリフィルが必要な場合がある

これらの考慮事項を守ることで、より安全な実装が可能になります。

まとめ

本記事では、Web Crypto API における署名・ハッシュ・暗号化の 3 つの技術について、その使い分けと実装方法を解説してきました。

各技術の使い分けポイント

  • ハッシュ:データの整合性確認や一意性チェックに使用。元に戻せない一方向関数で、パスワード保存やファイル検証に最適
  • デジタル署名:送信者の真正性証明とデータの完全性保証に使用。API 認証やドキュメント署名に不可欠
  • 暗号化:データの機密性保護に使用。個人情報や機密データを第三者から隠すために必須

これら 3 つの技術は、それぞれ異なる目的を持っており、適切に使い分けることで堅牢なセキュリティを実現できます。実務では、状況に応じて複数の技術を組み合わせることも重要ですね。

実装時のチェックリスト

  • データを元に戻す必要があるか?→ 必要なら暗号化、不要ならハッシュか署名
  • 送信者の証明が必要か?→ 必要ならデジタル署名
  • データを第三者から隠す必要があるか?→ 必要なら暗号化
  • 改ざん検知が必要か?→ ハッシュ、署名、または AES-GCM を検討

Web Crypto API は、ブラウザネイティブで高速かつ安全な暗号化処理を提供してくれます。本記事で紹介した実装パターンを参考に、ぜひ実際のプロジェクトで活用してみてください。セキュリティは一度実装すれば終わりではなく、継続的な見直しと改善が重要です。最新のベストプラクティスを常にキャッチアップしながら、安全な Web アプリケーションを構築していきましょう。

関連リンク