Pular para o conteúdo
ecdsa.com
WebCrypto · Node.js

WebCrypto (crypto.subtle)

A Web Cryptography API, embutida em todos os navegadores e no Node.js, no Deno e no Bun: ECDSA nas três curvas do NIST com assinaturas r‖s brutas, Ed25519 em todos eles e, por enquanto, algoritmos pós-quânticos só em alguns motores.
Licença
W3C Software and Document License (specification); each engine ships under its own license
Versão verificada
Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 ·
Código-fonte
github.com/w3c/webcrypto
Documentação
w3c.github.io/webcrypto/

Visão geral da compatibilidade

ECDSAEdDSA

Curvas ECDSA
P-256P-384P-521
EdDSA
Ed25519
Formatos de chave pública
spki-derjwksec1-uncompressed

O crypto.subtle é a interface de criptografia padrão da plataforma web. As mesmas chamadas — generateKey, importKey, sign, verify, exportKey — funcionam no Chromium, no Firefox e no WebKit e em runtimes de servidor como o Node.js, o que faz dele a escolha natural para código que precisa rodar dos dois lados. Ele só existe em contextos seguros: em uma página servida por http simples, crypto.subtle é undefined.

A especificação fixa o formato da assinatura ECDSA em r‖s bruto, 64 bytes na P-256, e o teste encontrou exatamente isso nos quatro motores. Isso coincide com JWS e JWT, então tokens ES256 não precisam de conversão, mas não coincide com o OpenSSL, o SHA256withECDSA do Java, o SignASN1 do Go nem o node:crypto, que usam todos ASN.1 DER. Como uma assinatura DER passada a verify() retorna um simples false em vez de um erro, a incompatibilidade parece uma falsificação; converta com o conversor DER ⇄ bruto ou teste as duas formas no verificador de assinaturas.

As chaves são a outra fonte comum de atrito. importKey("spki") recebe bytes DER, nunca texto PEM; "raw" recebe o ponto SEC1, e a forma comprimida é aceita pelo Node.js, pelo Chromium e pelo Firefox, mas recusada pelo WebKit — por isso, em código portável, envie o ponto não comprimido. Chaves privadas só entram como PKCS#8 DER ou JWK — os arquivos SEC1 EC PRIVATE KEY que o OpenSSL grava por padrão precisam ser convertidos para PKCS#8 antes, por exemplo com o conversor de JWK.

A lista de curvas se resume a P-256, P-384 e P-521: secp256k1 e Brainpool geram NotSupportedError em todos os motores, então chaves de Bitcoin e Ethereum precisam de uma biblioteca como a @noble/curves. O Ed25519 funciona nos quatro motores; o Ed448, só no Node.js. O suporte pós-quântico é desigual: o Node.js 24.22 implementa ML-DSA e ML-KEM com um aviso de recurso experimental, o Chromium 156 aceita ML-DSA e ML-KEM-768/1024, mas não ML-KEM-512, e o Firefox 157 e o WebKit 27.2 não têm nenhum dos dois — detecte o suporte antes de depender deles e experimente os algoritmos no playground de assinaturas pós-quânticas.

O exemplo mostra o padrão portável: informe o hash no objeto de algoritmo, importe chaves PEM externas após a decodificação para DER e lembre-se de que a saída de 64 bytes precisa ser encapsulada em DER antes que um verificador no estilo do OpenSSL consiga lê-la.

Os fatos e as verificações por trás deles

Nada abaixo foi copiado da documentação. A verificação content/libraries/verify/webcrypto/run.sh executou WebCrypto (crypto.subtle) Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 na imagem de contêiner node:24.22.0-bookworm-slim em 20 de outubro de 2026; a página precisa corresponder ao que a verificação imprimiu, e cada fato lista essas linhas.

  • Codificação da assinatura ECDSA

    raw

    sign() retornou exatamente 64 bytes de r‖s bruto na P-256 no Node.js, no Chromium, no Firefox e no WebKit; a especificação não define nenhuma opção DER.

    Registrado pela verificação
    fact ecdsa-signature-encoding = raw
  • Low-S ao assinar

    either

    As assinaturas não são normalizadas: valores high-S apareceram entre 64 assinaturas em todos os motores.

    Registrado pela verificação
    fact ecdsa-low-s-sign = either
  • High-S ao verificar

    accepts-high-s

    Todos os motores aceitaram uma assinatura cujo s foi substituído por n − s.

    Registrado pela verificação
    fact ecdsa-low-s-verify = accepts-high-s
  • Nonce do ECDSA

    random

    Nonce aleatório em todos os motores — assinar os mesmos dados duas vezes produz bytes diferentes, e não há opção determinística.

    Registrado pela verificação
    fact ecdsa-nonce = random
  • Entrada do hash na chamada de assinatura

    hashes-message

    sign() calcula o hash dos dados com o hash indicado no objeto de algoritmo; a assinatura de cada motor foi verificada contra o SHA-256 da mensagem em uma implementação independente. Não há como assinar um digest pré-calculado.

    Registrado pela verificação
    fact ecdsa-prehash = hashes-message
  • Assinatura ECDSA malformada

    returns-false

    Uma assinatura DER, uma assinatura de 63 bytes e um blob de 3 bytes fizeram verify() retornar false nos quatro motores; nada lança exceção.

    Registrado pela verificação
    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
  • Curvas ECDSA

    P-256P-384P-521

    Apenas P-256, P-384 e P-521; secp256k1, K-256 e brainpoolP256r1 geraram NotSupportedError em todos os motores.

    Registrado pela verificação
    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

    O Ed25519 assina e verifica nos quatro motores.

    Registrado pela verificação
    fact eddsa = Ed25519
  • Suporte a Ed448 por motor

    Node.js

    O Ed448 funcionou apenas no Node.js, que o marca como experimental; o Chromium, o Firefox e o WebKit o recusaram.

    Registrado pela verificação
    fact ed448-engines = Node.js
  • Algoritmos pós-quânticos

    none

    Nenhum algoritmo pós-quântico funciona ainda nos quatro motores, então código portável não pode depender de nenhum deles.

    Registrado pela verificação
    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
  • Suporte a ML-DSA por motor

    Node.jsChromium

    A assinatura e a verificação com ML-DSA-44, ML-DSA-65 e ML-DSA-87 funcionaram no Node.js (experimental) e no Chromium 156; o Firefox e o WebKit geraram NotSupportedError.

    Registrado pela verificação
    fact ml-dsa-engines = Node.js, Chromium
  • Suporte a ML-KEM por motor

    Node.jsChromium

    encapsulateBits e decapsulateBits com ML-KEM-768 funcionaram no Node.js e no Chromium 156; o Chromium recusou o ML-KEM-512, e o Firefox e o WebKit não ofereceram suporte a nenhum ML-KEM.

    Registrado pela verificação
    fact ml-kem-engines = Node.js, Chromium
  • Formatos de chave pública

    spki-derjwksec1-uncompressed

    Todos os motores importaram SPKI como bytes DER, uma JWK e o ponto não comprimido SEC1 ("raw"); texto PEM e um x‖y de 64 bytes sem o prefixo 0x04 geraram DataError.

    Registrado pela verificação
    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
  • Pontos comprimidos por motor

    Node.jsChromiumFirefox

    Um ponto comprimido de 33 bytes foi importado com "raw" no Node.js, no Chromium e no Firefox, mas o WebKit gerou DataError.

    Registrado pela verificação
    fact compressed-point-engines = Node.js, Chromium, Firefox
  • Formatos de chave privada

    pkcs8-derjwk

    Chaves privadas só são importadas como PKCS#8 DER ou JWK; um escalar de 32 bytes isolado foi rejeitado por todos os motores.

    Registrado pela verificação
    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

Uso

Assine com P-256, importe uma chave pública PEM após a decodificação para DER e verifique — a assinatura está na forma bruta de 64 bytes.
// 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));

O executor de trechos de código roda cada exemplo literalmente na imagem de contêiner node:24.22.0-bookworm-slim, e a execução falha se a saída não corresponder.

Comando
node usage.mjs
A saída deve corresponder a (expressão regular)
signature bytes: 64\nverified: true

Bibliotecas