Saltar al contenido
ecdsa.com
WebCrypto · Node.js

WebCrypto (crypto.subtle)

La API Web Cryptography, integrada en todos los navegadores y en Node.js, Deno y Bun: ECDSA en las tres curvas NIST con firmas r‖s en bruto, Ed25519 en todas partes y, por ahora, algoritmos poscuánticos solo en algunos motores.
Licencia
W3C Software and Document License (specification); each engine ships under its own license
Versión comprobada
Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 ·
Código fuente
github.com/w3c/webcrypto
Documentación
w3c.github.io/webcrypto/

Compatibilidad de un vistazo

ECDSAEdDSA

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

crypto.subtle es la interfaz criptográfica estándar de la plataforma web. Las mismas llamadas —generateKey, importKey, sign, verify, exportKey— funcionan en Chromium, Firefox y WebKit y en entornos de ejecución de servidor como Node.js, lo que la convierte en la opción natural para el código que debe ejecutarse en ambos lados. Solo existe en contextos seguros: en una página servida por http simple, crypto.subtle es undefined.

La especificación fija el formato de firma ECDSA en r‖s en bruto, 64 bytes en P-256, y la comprobación encontró exactamente eso en los cuatro motores. Eso coincide con JWS y JWT, así que los tokens ES256 no necesitan conversión, pero no coincide con OpenSSL, el SHA256withECDSA de Java, el SignASN1 de Go ni node:crypto, que usan todos DER ASN.1. Como una firma DER pasada a verify() devuelve un simple false en lugar de un error, la discrepancia parece una falsificación; convierte la firma con el conversor DER ⇄ en bruto o comprueba ambas formas en el verificador de firmas.

Las claves son la otra fuente habitual de fricción. importKey("spki") recibe bytes DER, nunca texto PEM; "raw" recibe el punto SEC1, y el punto comprimido lo aceptan Node.js, Chromium y Firefox pero lo rechaza WebKit, así que envía el punto sin comprimir si quieres código portable. Las claves privadas solo se importan como PKCS#8 DER o JWK: los archivos SEC1 EC PRIVATE KEY que OpenSSL escribe por defecto deben convertirse antes a PKCS#8, por ejemplo con el conversor de JWK.

La lista de curvas se limita a P-256, P-384 y P-521: secp256k1 y Brainpool lanzan NotSupportedError en todas partes, así que las claves de Bitcoin y Ethereum necesitan una biblioteca como @noble/curves. Ed25519 funciona en los cuatro motores; Ed448, solo en Node.js. La compatibilidad poscuántica es desigual: Node.js 24.22 implementa ML-DSA y ML-KEM con una advertencia de función experimental, Chromium 156 acepta ML-DSA y ML-KEM-768/1024 pero no ML-KEM-512, y Firefox 157 y WebKit 27.2 no tienen ninguno de los dos; detecta la compatibilidad antes de depender de ellos y prueba los algoritmos en la herramienta de firmas poscuánticas.

El ejemplo muestra el patrón portable: indica el hash en el objeto del algoritmo, importa las claves PEM de otros sistemas decodificándolas antes a DER y recuerda que la salida de 64 bytes debe envolverse en DER antes de que un verificador al estilo de OpenSSL pueda leerla.

Datos y las comprobaciones que los respaldan

Nada de lo que sigue está copiado de la documentación. La comprobación content/libraries/verify/webcrypto/run.sh ejecutó WebCrypto (crypto.subtle) Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 en la imagen de contenedor node:24.22.0-bookworm-slim el 20 de octubre de 2026; la página debe coincidir con lo que imprimió, y cada dato cita esas líneas.

  • Codificación de la firma ECDSA

    raw

    sign() devolvió exactamente 64 bytes de r‖s en bruto en P-256 en Node.js, Chromium, Firefox y WebKit; la especificación no define ninguna opción DER.

    Registrado por la comprobación
    fact ecdsa-signature-encoding = raw
  • Low-S al firmar

    either

    Las firmas no se normalizan: aparecieron valores high-S entre 64 firmas en todos los motores.

    Registrado por la comprobación
    fact ecdsa-low-s-sign = either
  • High-S al verificar

    accepts-high-s

    Todos los motores aceptaron una firma cuyo s se había sustituido por n − s.

    Registrado por la comprobación
    fact ecdsa-low-s-verify = accepts-high-s
  • Nonce de ECDSA

    random

    Nonce aleatorio en todos los motores: firmar dos veces los mismos datos da bytes distintos, y no hay ninguna opción determinista.

    Registrado por la comprobación
    fact ecdsa-nonce = random
  • De qué calcula el hash la llamada de firma

    hashes-message

    sign() calcula el hash de los datos con el hash indicado en el objeto del algoritmo; la firma de cada motor se verificó contra el SHA-256 del mensaje en una implementación independiente. No hay forma de firmar un resumen precalculado.

    Registrado por la comprobación
    fact ecdsa-prehash = hashes-message
  • Firma ECDSA mal formada

    returns-false

    Una firma DER, una firma de 63 bytes y un blob de 3 bytes hicieron que verify() devolviera false en los cuatro motores; nada lanza una excepción.

    Registrado por la comprobación
    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

    Solo P-256, P-384 y P-521; secp256k1, K-256 y brainpoolP256r1 lanzaron NotSupportedError en todos los motores.

    Registrado por la comprobación
    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 firma y verifica en los cuatro motores.

    Registrado por la comprobación
    fact eddsa = Ed25519
  • Compatibilidad con Ed448 por motor

    Node.js

    Ed448 solo funcionó en Node.js, que lo marca como experimental; Chromium, Firefox y WebKit lo rechazaron.

    Registrado por la comprobación
    fact ed448-engines = Node.js
  • Algoritmos poscuánticos

    none

    Todavía no hay ningún algoritmo poscuántico que funcione en los cuatro motores, así que el código portable no puede depender de ninguno.

    Registrado por la comprobación
    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
  • Compatibilidad con ML-DSA por motor

    Node.jsChromium

    Con ML-DSA-44, ML-DSA-65 y ML-DSA-87 se firmó y se verificó en Node.js (experimental) y en Chromium 156; Firefox y WebKit lanzaron NotSupportedError.

    Registrado por la comprobación
    fact ml-dsa-engines = Node.js, Chromium
  • Compatibilidad con ML-KEM por motor

    Node.jsChromium

    encapsulateBits y decapsulateBits con ML-KEM-768 funcionaron en Node.js y en Chromium 156; Chromium rechazó ML-KEM-512, y Firefox y WebKit no admitían ninguna variante de ML-KEM.

    Registrado por la comprobación
    fact ml-kem-engines = Node.js, Chromium
  • Formatos de clave pública

    spki-derjwksec1-uncompressed

    Todos los motores importaron SPKI como bytes DER, una JWK y el punto SEC1 en formato sin comprimir ("raw"); el texto PEM y un x‖y de 64 bytes sin el prefijo 0x04 lanzaron DataError.

    Registrado por la comprobación
    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
  • Puntos comprimidos por motor

    Node.jsChromiumFirefox

    Un punto comprimido de 33 bytes se importó con "raw" en Node.js, Chromium y Firefox, pero WebKit lanzó DataError.

    Registrado por la comprobación
    fact compressed-point-engines = Node.js, Chromium, Firefox
  • Formatos de clave privada

    pkcs8-derjwk

    Las claves privadas solo se importan como PKCS#8 DER o JWK; todos los motores rechazaron un escalar de 32 bytes sin envoltorio.

    Registrado por la comprobación
    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

Firma con P-256, importa una clave pública PEM decodificándola a DER y verifica: la firma está en la forma en bruto 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));

El arnés de pruebas de fragmentos de código ejecuta cada ejemplo tal cual en la imagen de contenedor node:24.22.0-bookworm-slim, y la ejecución falla si la salida no coincide.

Comando
node usage.mjs
La salida debe coincidir (expresión regular)
signature bytes: 64\nverified: true

Bibliotecas