本文へスキップ
ecdsa.com
WebCrypto · Node.js

WebCrypto (crypto.subtle)

すべてのブラウザーと Node.js、Deno、Bun に組み込まれた Web Cryptography API です。ECDSA は NIST の 3 つの曲線で生の r‖s 署名を使い、Ed25519 はどこでも使えますが、ポスト量子アルゴリズムは今のところ一部のエンジンでしか使えません。
ライセンス
W3C Software and Document License (specification); each engine ships under its own license
確認したバージョン
Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 ·
ソースコード
github.com/w3c/webcrypto
ドキュメント
w3c.github.io/webcrypto/

互換性の概要

ECDSAEdDSA

ECDSA の曲線
P-256P-384P-521
EdDSA
Ed25519
公開鍵の形式
spki-derjwksec1-uncompressed
秘密鍵の形式
pkcs8-derjwk

crypto.subtle は、Web プラットフォームの標準の暗号インターフェースです。同じ呼び出し(generateKey、importKey、sign、verify、exportKey)が Chromium、Firefox、WebKit でも、Node.js などのサーバーランタイムでも動作するため、ブラウザーとサーバーの両方で動かす必要があるコードには自然な選択肢です。使えるのはセキュアコンテキストだけで、通常の http のページでは crypto.subtle は undefined になります。

仕様は ECDSA 署名の形式を生の r‖s(P-256 では 64 バイト)に固定しており、チェックでも 4 つのエンジンすべてでそのとおりの結果になりました。これは JWS や JWT と一致するため ES256 トークンに変換は不要ですが、OpenSSL、Java の SHA256withECDSA、Go の SignASN1、node:crypto とは一致しません。これらはいずれも ASN.1 DER を使います。DER 署名を verify() に渡すとエラーではなく単に false が返るため、形式の不一致が偽造された署名のように見えます。DER ⇄ 生形式の変換ツールで変換するか、ECDSA 署名検証ツールで両方の形式を確認してください。

もう 1 つのよくあるつまずきの原因は鍵です。importKey("spki") が受け付けるのは DER のバイト列で、PEM テキストは受け付けません。"raw" は SEC1 の点を受け付けますが、圧縮形式は Node.js、Chromium、Firefox では受け入れられる一方、WebKit では拒否されます。移植性のあるコードでは非圧縮形式の点を渡してください。秘密鍵は PKCS#8 DER か JWK でしかインポートできません。OpenSSL がデフォルトで書き出す SEC1 の EC PRIVATE KEY ファイルは、JWK 変換ツールなどで先に PKCS#8 に変換する必要があります。

対応する曲線は P-256、P-384、P-521 だけです。secp256k1 と Brainpool はどのエンジンでも NotSupportedError になるため、Bitcoin や Ethereum の鍵には @noble/curves などのライブラリが必要です。Ed25519 は 4 つのエンジンすべてで動作しますが、Ed448 は Node.js だけです。ポスト量子暗号への対応はまちまちです。Node.js 24.22 は ML-DSA と ML-KEM を実験的機能の警告付きで実装し、Chromium 156 は ML-DSA と ML-KEM-768/1024 を受け付けますが ML-KEM-512 は受け付けません。Firefox 157 と WebKit 27.2 はどちらにも対応していません。利用する前に機能検出を行い、ポスト量子署名ラボでアルゴリズムを試してください。

サンプルは移植性のあるパターンを示しています。アルゴリズムオブジェクトでハッシュを指定し、外部の PEM 鍵は先に DER にデコードしてからインポートします。また、64 バイトの出力は DER でラップしないと OpenSSL 方式の検証コードでは読み取れないことに注意してください。

事実とその根拠となるチェック

以下の内容はドキュメントからの転記ではありません。2026年10月20日に、チェック content/libraries/verify/webcrypto/run.sh がコンテナイメージ node:24.22.0-bookworm-slim 内で WebCrypto (crypto.subtle) Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 を実行しました。このページの内容はその出力と一致していなければならず、各事実には該当する出力行を示しています。

  • ECDSA 署名のエンコーディング

    raw

    Node.js、Chromium、Firefox、WebKit のいずれでも、sign() は P-256 でちょうど 64 バイトの生の r‖s を返しました。仕様には DER を選ぶオプションがありません。

    チェックによる記録
    fact ecdsa-signature-encoding = raw
  • 署名時の low-S

    either

    署名は正規化されません。どのエンジンでも、64 個の署名の中に high-S の値が現れました。

    チェックによる記録
    fact ecdsa-low-s-sign = either
  • 検証時の high-S

    accepts-high-s

    すべてのエンジンが、s を n − s に置き換えた署名を受け入れました。

    チェックによる記録
    fact ecdsa-low-s-verify = accepts-high-s
  • ECDSA の nonce

    random

    どのエンジンでも nonce はランダムです。同じデータに 2 回署名すると異なるバイト列になり、決定的 nonce を使うオプションはありません。

    チェックによる記録
    fact ecdsa-nonce = random
  • 署名呼び出しがハッシュする対象

    hashes-message

    sign() は、アルゴリズムオブジェクトで指定したハッシュでデータをハッシュします。各エンジンの署名は、独立した別の実装でメッセージの SHA-256 に対して検証できました。事前に計算したダイジェストに署名する方法はありません。

    チェックによる記録
    fact ecdsa-prehash = hashes-message
  • 不正な形式の ECDSA 署名

    returns-false

    DER 署名、63 バイトの署名、3 バイトのデータのいずれを渡しても、4 つのエンジンすべてで verify() は false を返しました。例外はスローされません。

    チェックによる記録
    fact ecdsa-malformed-signature = returns-false
    evidence ecdsa-malformed-signature: Node.js: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: Chromium: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: Firefox: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: WebKit: DER false, 63 bytes false, 3 bytes false
  • ECDSA の曲線

    P-256P-384P-521

    P-256、P-384、P-521 のみです。secp256k1、K-256、brainpoolP256r1 は、どのエンジンでも NotSupportedError になりました。

    チェックによる記録
    fact ecdsa-curves = P-256, P-384, P-521
    evidence ecdsa-curves: Node.js: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: Chromium: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: Firefox: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: WebKit: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
  • EdDSA

    Ed25519

    Ed25519 は 4 つのエンジンすべてで署名と検証ができます。

    チェックによる記録
    fact eddsa = Ed25519
  • エンジン別の Ed448 対応

    Node.js

    Ed448 が動作したのは Node.js だけで、Node.js でも実験的機能とされています。Chromium、Firefox、WebKit は Ed448 を拒否しました。

    チェックによる記録
    fact ed448-engines = Node.js
  • ポスト量子アルゴリズム

    none

    4 つのエンジンすべてで動作するポスト量子アルゴリズムはまだないため、移植性のあるコードではポスト量子アルゴリズムに頼れません。

    チェックによる記録
    fact pqc = none
    evidence pqc: Node.js: ML-DSA-44 true; ML-DSA-65 true; ML-DSA-87 true; ML-KEM-512 true; ML-KEM-768 true; ML-KEM-1024 true
    evidence pqc: Chromium: ML-DSA-44 true; ML-DSA-65 true; ML-DSA-87 true; ML-KEM-512 NotSupportedError; ML-KEM-768 true; ML-KEM-1024 true
    evidence pqc: Firefox: ML-DSA-44 NotSupportedError; ML-DSA-65 NotSupportedError; ML-DSA-87 NotSupportedError; ML-KEM-512 NotSupportedError; ML-KEM-768 NotSupportedError; ML-KEM-1024 NotSupportedError
    evidence pqc: WebKit: ML-DSA-44 NotSupportedError; ML-DSA-65 NotSupportedError; ML-DSA-87 NotSupportedError; ML-KEM-512 TypeError; ML-KEM-768 TypeError; ML-KEM-1024 TypeError
  • エンジン別の ML-DSA 対応

    Node.jsChromium

    ML-DSA-44、ML-DSA-65、ML-DSA-87 は、Node.js(実験的機能)と Chromium 156 で署名と検証ができました。Firefox と WebKit では NotSupportedError になりました。

    チェックによる記録
    fact ml-dsa-engines = Node.js, Chromium
  • エンジン別の ML-KEM 対応

    Node.jsChromium

    ML-KEM-768 での encapsulateBits と decapsulateBits は、Node.js と Chromium 156 で動作しました。Chromium は ML-KEM-512 を拒否し、Firefox と WebKit は ML-KEM にまったく対応していませんでした。

    チェックによる記録
    fact ml-kem-engines = Node.js, Chromium
  • 公開鍵の形式

    spki-derjwksec1-uncompressed

    どのエンジンも、DER のバイト列としての SPKI、JWK、SEC1 の非圧縮形式の点("raw")をインポートできました。PEM テキストと、0x04 のプレフィックスがない 64 バイトの x‖y は DataError になりました。

    チェックによる記録
    fact public-key-formats = spki-der, jwk, sec1-uncompressed
    evidence public-key-formats: Node.js: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: Chromium: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: Firefox: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: WebKit: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed DataError; raw-xy DataError
  • エンジン別の圧縮形式への対応

    Node.jsChromiumFirefox

    33 バイトの圧縮形式の点は、Node.js、Chromium、Firefox では "raw" でインポートできましたが、WebKit では DataError になりました。

    チェックによる記録
    fact compressed-point-engines = Node.js, Chromium, Firefox
  • 秘密鍵の形式

    pkcs8-derjwk

    秘密鍵は PKCS#8 DER か JWK でしかインポートできません。32 バイトのスカラー単体は、どのエンジンでも拒否されました。

    チェックによる記録
    fact private-key-formats = pkcs8-der, jwk
    evidence private-key-formats: Node.js: pkcs8-der true; jwk true; raw-scalar SyntaxError
    evidence private-key-formats: Chromium: pkcs8-der true; jwk true; raw-scalar SyntaxError
    evidence private-key-formats: Firefox: pkcs8-der true; jwk true; raw-scalar DataError
    evidence private-key-formats: WebKit: pkcs8-der true; jwk true; raw-scalar SyntaxError

使い方

P-256 で署名し、PEM 公開鍵を DER にデコードしてインポートしてから検証します。署名は 64 バイトの生形式です。
// The same code runs in browsers, Deno, Bun and Node.js (globalThis.crypto).
const { subtle } = globalThis.crypto;
const curve = { name: "ECDSA", namedCurve: "P-256" };
const ecdsa = { name: "ECDSA", hash: "SHA-256" };

const { privateKey, publicKey } = await subtle.generateKey(curve, true, ["sign", "verify"]);
const data = new TextEncoder().encode("order #1042 approved");
const signature = new Uint8Array(await subtle.sign(ecdsa, privateKey, data));

// A PEM public key from another system: importKey("spki") wants the DER bytes,
// so strip the armour and base64-decode first. PEM text itself is a DataError.
const spki = new Uint8Array(await subtle.exportKey("spki", publicKey));
const pem = `-----BEGIN PUBLIC KEY-----\n${btoa(String.fromCharCode(...spki))}\n-----END PUBLIC KEY-----`;
const der = Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s/g, "")), (c) => c.charCodeAt(0));
const imported = await subtle.importKey("spki", der, curve, false, ["verify"]);

// Always 64 bytes on P-256: raw r||s, never DER. Convert before handing it to OpenSSL or Java.
console.log("signature bytes:", signature.length);
console.log("verified:", await subtle.verify(ecdsa, imported, signature, data));

スニペットのテストハーネスは、すべてのサンプルをコンテナイメージ node:24.22.0-bookworm-slim 内でそのまま実行し、出力が一致しなければ実行は失敗します。

コマンド
node usage.mjs
一致すべき出力(正規表現)
signature bytes: 64\nverified: true

ライブラリ