WebCrypto でデータに署名し、検証する

ウェブ機能: Web Cryptography

Web Cryptography API(crypto.subtle)を使用して、耐量子(ML-DSA)または従来型('Ed25519'、'ECDSA')の非対称鍵を使用して、ブラウザでメッセージ、トークン、アーティファクトに署名し、検証します。デジタル署名は、共有秘密鍵を必要とせずに、データの完全性と署名者の真正性を保証します。共有シークレットを確立して機密ペイロードを暗号化する方法については、WebCrypto を使用してクライアントサイドでデータを暗号化するをご覧ください。

1. アルゴリズムを選択して鍵ペアを生成する

ML-DSA(Module-Lattice-Based Digital Signature Algorithm、NIST FIPS 204 で標準化)は、従来型のコンピュータと量子コンピュータの両方による攻撃に対して耐性がある、ポスト量子格子ベースのデジタル署名を提供します。

汎用署名には、デフォルトで 'ML-DSA-65' を使用します。帯域幅が制限されたチャネルで、より小さな公開鍵と署名が必要で、128 ビットのセキュリティで十分な場合は 'ML-DSA-44' を選択します。セキュリティ ポリシーで NIST カテゴリ 5(CNSA 2.0)への準拠が必要な場合は 'ML-DSA-87' を選択します。

アルゴリズム名 セキュリティ レベルとユースケース 公開鍵('raw-public') 署名のサイズ
'ML-DSA-44' NIST カテゴリ 2(約 128 ビットのセキュリティ)。最小の鍵と署名 1,312 バイト 2,420 バイト
'ML-DSA-65' NIST カテゴリ 3(約 192 ビットのセキュリティ)。推奨されるデフォルト 1,952 バイト 3,309 バイト
'ML-DSA-87' NIST カテゴリ 5(約 256 ビットのセキュリティ)、最高レベルのセキュリティ マージン 2,592 バイト 4,627 バイト
  • アルゴリズム名として 'ML-DSA-44'、'ML-DSA-65'、'ML-DSA-87' のいずれかを渡します('Dilithium' や 'CRYSTALS-Dilithium' などの標準化前の名前は NotSupportedError をスローします)。
  • ML-DSA と 'Ed25519' のどちらも hash パラメータを受け取りません。
  • generateKey() で鍵ペアを生成するときに ['sign', 'verify'] を渡します。
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. 鍵のエクスポートとインポート

'ML-DSA-44'、'ML-DSA-65'、'ML-DSA-87' は、importKey() と exportKey() で従来の 'raw' 形式を拒否し、NotSupportedError を返します。鍵をエクスポートまたはインポートする場合は、常に明示的な形式を使用します。

  • 'raw-public': 公開鍵のバイト数(1312、1952、または 2592 バイト)。公開鍵をインポートするときに、鍵の使用目的として ['verify'] を渡します。
  • 'raw-seed': 3 つの ML-DSA アルゴリズムすべてで使用される 32 バイトの秘密鍵シード。'raw' と 'raw-private' の両方が ML-DSA で NotSupportedError をスローします。'raw-seed'、'pkcs8'(54 バイト)、または 'jwk'(kty: 'AKP')を使用して秘密鍵を保存または送信します。秘密鍵をインポートするときに、鍵の使用方法として ['sign'] を渡します(['sign', 'verify'] を importKey() に渡すと SyntaxError がスローされます)。
  • 'spki'、'pkcs8'、'jwk': 3 つの ML-DSA アルゴリズムすべてでサポートされています。

検証用の公開鍵は、常に 'raw-public' を使用してエクスポートしてください。これにより、公開鍵のバイトをシリアル化したり、検証者と共有したりできます。

// Export the public key bytes on the signer
const publicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  signerKeyPair.publicKey,
);

// Import the public key bytes on the verifier (usage must be ['verify'] only)
const verifierPublicKey = await crypto.subtle.importKey(
  'raw-public',
  publicKeyBytes,
  'ML-DSA-65',
  true,
  ['verify'],
);

秘密署名 CryptoKey のみを保持しており(extractable: false を含む場合でも)、対応する公開 CryptoKey が必要な場合は、['verify'] を指定して crypto.subtle.getPublicKey() を呼び出します。

const publicKey = await crypto.subtle.getPublicKey(
  signerKeyPair.privateKey,
  ['verify'],
);

3. ペイロードに署名して検証する

秘密鍵を使用して crypto.subtle.sign() を呼び出して署名を生成し、公開鍵を使用して crypto.subtle.verify() を呼び出して署名を確認します。

const payloadBytes = new TextEncoder().encode(
  '{"artifact":"release-v2.4.0.tar.gz","sha256":"9f86d08..."}',
);

const signatureBuffer = await crypto.subtle.sign(
  'ML-DSA-65',
  signerKeyPair.privateKey,
  payloadBytes,
);

const isValid = await crypto.subtle.verify(
  'ML-DSA-65',
  verifierPublicKey,
  signatureBuffer,
  payloadBytes,
);

ContextParams を使用したドメインの分離

ある目的で作成された署名が、同じ鍵ペアを共有するアプリケーションまたはプロトコルの別の部分で再生されないようにするには、署名をドメイン分離 context にバインドします。

アルゴリズム名の文字列をそのまま渡すのではなく、ContextParams 辞書({ name, context })を sign() と verify() に渡します。ここで、context は BufferSource(Uint8Array または ArrayBuffer)です。

const contextBytes = new TextEncoder().encode('release-manifest-v1');

const signatureBuffer = await crypto.subtle.sign(
  {
    name: 'ML-DSA-65',
    context: contextBytes,
  },
  signerKeyPair.privateKey,
  payloadBytes,
);

const isValid = await crypto.subtle.verify(
  {
    name: 'ML-DSA-65',
    context: contextBytes,
  },
  verifierPublicKey,
  signatureBuffer,
  payloadBytes,
);
  • 255 バイトの上限: context は 0~255 バイトを受け入れます(context を省略すると、空の new Uint8Array(0) を渡すことと同じになります)。255 バイトを超えると、OperationError で拒否されます。
  • 完全一致が必要: crypto.subtle.verify() は、検証ツールが crypto.subtle.sign() で使用されたものと完全に一致する context バイトを渡した場合にのみ true を返します。

代替戦略

Baseline ターゲットが SubtleCrypto.supports() または ML-DSA をまだサポートしていない場合は、静的 SubtleCrypto.supports() メソッドを使用してサポートを検出し、従来の 'Ed25519'(または 'Ed25519' サポート前のブラウザをターゲットとする場合は 'P-256' と 'SHA-256' を使用した 'ECDSA')にフォールバックします。

  • crypto.subtle ではなく SubtleCrypto コンストラクタで SubtleCrypto.supports() を呼び出す: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') は、ブール値を返す同期静的メソッドです(crypto.subtle.supports は undefined)。'sign'、'verify'、'generateKey'、'importKey'、'exportKey'、'getPublicKey' をプローブします(または、SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context }) などの ContextParams 辞書を渡します)。
  • フォールバックの鍵形式('raw'): 最新の WebCrypto をサポートするブラウザは 'Ed25519' と 'ECDSA' で 'raw-public' も受け入れますが、古いブラウザをターゲットとするフォールバック パスでは 'raw' を使用します。
  • フォールバックでのドメイン分離: 従来の WebCrypto アルゴリズム('Ed25519' と 'ECDSA')は、アルゴリズム ディクショナリから context プロパティを読み取りません(WebIDL は、Algorithm と EcdsaParams の不明なディクショナリ プロパティをサイレントに無視します)。フォールバック パスのペイロードに長さプレフィックス付きの context ヘッダーを付加して、ドメイン分離が両方のスイートで一貫して動作するようにします。
const ML_DSA_PUBLIC_KEY_ALGORITHMS = {
  1312: 'ML-DSA-44',
  1952: 'ML-DSA-65',
  2592: 'ML-DSA-87',
};

export function supportsPostQuantumSignatures() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('sign', 'ML-DSA-65')
  );
}

export async function generateSigningKeyPair() {
  if (supportsPostQuantumSignatures()) {
    const keyPair = await crypto.subtle.generateKey('ML-DSA-65', false, ['sign', 'verify']);
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return {
      suite: 'ML-DSA-65',
      keyFormat: 'raw-public',
      keyPair,
      publicKeyBytes,
    };
  }

  // Classical fallback: Ed25519
  const keyPair = await crypto.subtle.generateKey('Ed25519', false, ['sign', 'verify']);
  const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey));
  return {
    suite: 'Ed25519',
    keyFormat: 'raw',
    keyPair,
    publicKeyBytes,
  };
}

function frameFallbackInput(payloadBytes, contextBytes) {
  if (contextBytes.byteLength > 255) {
    throw new DOMException('Context must not exceed 255 bytes', 'OperationError');
  }
  const framed = new Uint8Array(1 + contextBytes.byteLength + payloadBytes.byteLength);
  framed[0] = contextBytes.byteLength;
  framed.set(contextBytes, 1);
  framed.set(payloadBytes, 1 + contextBytes.byteLength);
  return framed;
}

export async function signPayload(
  privateKey,
  payloadBytes,
  contextBytes = new Uint8Array(0),
) {
  if (privateKey.algorithm.name.startsWith('ML-DSA')) {
    const sig = await crypto.subtle.sign(
      { name: privateKey.algorithm.name, context: contextBytes },
      privateKey,
      payloadBytes,
    );
    return new Uint8Array(sig);
  }

  const framed = frameFallbackInput(payloadBytes, contextBytes);
  const sig = await crypto.subtle.sign('Ed25519', privateKey, framed);
  return new Uint8Array(sig);
}

export async function verifyPayload(
  publicKeyOrBytes,
  signatureBytes,
  payloadBytes,
  contextBytes = new Uint8Array(0),
) {
  const publicKey =
    publicKeyOrBytes instanceof CryptoKey
      ? publicKeyOrBytes
      : await (() => {
          const bytes =
            publicKeyOrBytes instanceof Uint8Array
              ? publicKeyOrBytes
              : new Uint8Array(publicKeyOrBytes);
          const mlDsaName = ML_DSA_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return mlDsaName
            ? crypto.subtle.importKey('raw-public', bytes, mlDsaName, true, ['verify'])
            : crypto.subtle.importKey('raw', bytes, 'Ed25519', true, ['verify']);
        })();

  if (publicKey.algorithm.name.startsWith('ML-DSA')) {
    return crypto.subtle.verify(
      { name: publicKey.algorithm.name, context: contextBytes },
      publicKey,
      signatureBytes,
      payloadBytes,
    );
  }

  const framed = frameFallbackInput(payloadBytes, contextBytes);
  return crypto.subtle.verify('Ed25519', publicKey, signatureBytes, framed);
}