Перейти к содержимому
ecdsa.com
WebCrypto · Node.js

WebCrypto (crypto.subtle)

Web Cryptography API, встроенный во все браузеры, а также в Node.js, Deno и Bun: ECDSA на трёх кривых NIST с подписями в формате raw 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

crypto.subtle — стандартный криптографический интерфейс веб-платформы. Одни и те же вызовы — generateKey, importKey, sign, verify, exportKey — работают в Chromium, Firefox и WebKit, а также в серверных средах выполнения, например в Node.js, поэтому это естественный выбор для кода, который должен работать на обеих сторонах. Интерфейс доступен только в безопасных контекстах: на странице, открытой по обычному HTTP, crypto.subtle равен undefined.

Спецификация фиксирует формат подписи ECDSA как raw r‖s (64 байта на P-256), и проверка обнаружила именно его во всех четырёх движках. Это совпадает с JWS и JWT, поэтому токены ES256 не нужно преобразовывать, но не совпадает с OpenSSL, SHA256withECDSA в Java, SignASN1 в Go и node:crypto: все они работают с ASN.1 DER. Подпись в DER, переданная в verify(), даёт просто false, а не ошибку, поэтому несовпадение форматов выглядит как подделка; преобразуйте подпись конвертером DER ⇄ raw или проверьте обе формы в инструменте проверки подписей.

Второй частый источник проблем — ключи. importKey("spki") принимает байты DER и никогда не принимает текст PEM; "raw" принимает точку SEC1, причём Node.js, Chromium и Firefox принимают и сжатую форму, а WebKit её отклоняет, поэтому в переносимом коде передавайте несжатую точку. Закрытые ключи импортируются только как PKCS#8 DER или JWK: файлы SEC1 EC PRIVATE KEY, которые OpenSSL записывает по умолчанию, сначала нужно преобразовать в PKCS#8, например конвертером JWK.

Список кривых ограничен P-256, P-384 и P-521: secp256k1 и Brainpool везде приводят к NotSupportedError, поэтому для ключей Bitcoin и Ethereum нужна библиотека, например @noble/curves. Ed25519 работает во всех четырёх движках, 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.

Факты и проверки, на которых они основаны

Ничего из приведённого ниже не скопировано из документации. 20 октября 2026 г. проверка content/libraries/verify/webcrypto/run.sh запустила WebCrypto (crypto.subtle) Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 в образе контейнера node:24.22.0-bookworm-slim; содержимое страницы должно совпадать с выводом проверки, а у каждого факта приведены соответствующие строки вывода.

  • Кодировка подписи ECDSA

    raw

    sign() вернул ровно 64 байта в формате raw r‖s на P-256 в Node.js, Chromium, Firefox и WebKit; варианта с 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
  • Nonce в ECDSA

    random

    Случайный nonce во всех движках: если подписать одни и те же данные дважды, получатся разные байты, а детерминированного варианта нет.

    Что записала проверка
    fact ecdsa-nonce = random
  • Что хеширует вызов подписи

    hashes-message

    sign() хеширует данные хеш-функцией, указанной в объекте алгоритма; подпись каждого движка прошла проверку по SHA-256 от сообщения в независимой реализации. Подписать заранее вычисленный дайджест нельзя.

    Что записала проверка
    fact ecdsa-prehash = hashes-message
  • Некорректная подпись ECDSA

    returns-false

    Подпись в DER, подпись длиной 63 байта и 3-байтовый фрагмент данных во всех четырёх движках заставили 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 работают во всех четырёх движках.

    Что записала проверка
    fact eddsa = Ed25519
  • Поддержка Ed448 по движкам

    Node.js

    Ed448 заработал только в Node.js, который помечает его как экспериментальный; Chromium, Firefox и WebKit его отклонили.

    Что записала проверка
    fact ed448-engines = Node.js
  • Постквантовые алгоритмы

    none

    Ни один постквантовый алгоритм пока не работает во всех четырёх движках, поэтому переносимый код не может полагаться ни на один из них.

    Что записала проверка
    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

    encapsulateBits и decapsulateBits с ML-KEM-768 работали в Node.js и Chromium 156; Chromium отклонил ML-KEM-512, а Firefox и WebKit вообще не поддерживали ML-KEM.

    Что записала проверка
    fact ml-kem-engines = Node.js, Chromium
  • Форматы открытых ключей

    spki-derjwksec1-uncompressed

    Каждый движок импортировал ключ из SPKI в байтах DER, из JWK и из несжатой точки SEC1 ("raw"); текст PEM и 64-байтовое значение x‖y без префикса 0x04 вызвали 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 байта импортировалась через "raw" в Node.js, Chromium и Firefox, а 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-байтовой форме raw.
// 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

Инструменты

Термины

Библиотеки

Статьи

Рецепты

Ошибки

Библиотеки