Firma y verifica datos con WebCrypto

Funciones web: Criptografía web

Usa la API de Web Cryptography (crypto.subtle) para firmar y verificar mensajes, tokens o artefactos en el navegador con claves asimétricas resistentes a la computación cuántica (ML-DSA) o clásicas ('Ed25519', 'ECDSA'). Las firmas digitales garantizan la integridad de los datos y la autenticidad del firmante sin necesidad de claves secretas compartidas. Para establecer secretos compartidos y encriptar cargas útiles confidenciales, consulta Encripta datos del cliente con WebCrypto.

1. Elige un algoritmo y genera un par de claves

ML-DSA (algoritmo de firma digital basado en retículos de módulos, estandarizado en NIST FIPS 204) proporciona firmas digitales poscuánticas basadas en retículos resistentes a ataques de computadoras clásicas y cuánticas.

Usa 'ML-DSA-65' de forma predeterminada para la firma de uso general. Elige 'ML-DSA-44' cuando necesites claves públicas y firmas más pequeñas en canales con ancho de banda limitado y la seguridad de 128 bits sea suficiente, o 'ML-DSA-87' cuando una política de seguridad requiera el cumplimiento de la categoría 5 del NIST (CNSA 2.0).

Nombre del algoritmo Nivel de seguridad y caso de uso Clave pública ('raw-public') Tamaño de la firma
'ML-DSA-44' Categoría 2 del NIST (seguridad de ~128 bits); claves y firmas más pequeñas 1,312 bytes 2,420 bytes
'ML-DSA-65' Categoría 3 del NIST (seguridad de ~192 bits); opción predeterminada recomendada 1,952 bytes 3309 bytes
'ML-DSA-87' Categoría 5 del NIST (seguridad de ~256 bits); margen de seguridad más alto 2,592 bytes 4627 bytes
  • Pasa 'ML-DSA-44', 'ML-DSA-65' o 'ML-DSA-87' como el nombre del algoritmo (los nombres anteriores a la estandarización, como 'Dilithium' o 'CRYSTALS-Dilithium', arrojan NotSupportedError).
  • Ni ML-DSA ni 'Ed25519' toman un parámetro hash.
  • Pasa ['sign', 'verify'] cuando generes un par de claves con generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Importa y exporta claves

'ML-DSA-44', 'ML-DSA-65' y 'ML-DSA-87' rechazan el formato heredado 'raw' en importKey() y exportKey() con un NotSupportedError. Exporta o importa siempre las claves con un formato explícito:

  • 'raw-public': Bytes de clave pública (1312, 1952 o 2592 bytes). Pasa ['verify'] como el uso de la clave cuando importes una clave pública.
  • 'raw-seed': Es el valor de origen de la clave privada de 32 bytes para los tres algoritmos de ML-DSA. Tanto 'raw' como 'raw-private' arrojan NotSupportedError en ML-DSA: almacena o transmite claves privadas con 'raw-seed', 'pkcs8' (54 bytes) o 'jwk' (kty: 'AKP'). Pasa ['sign'] como el uso de la clave cuando importes una clave privada (pasar ['sign', 'verify'] a importKey() arroja SyntaxError).
  • 'spki', 'pkcs8' y 'jwk': Se admiten para los tres algoritmos de ML-DSA.

Exporta siempre la clave pública de verificación con 'raw-public' para que los bytes de la clave pública se puedan serializar o compartir con los verificadores:

// 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'],
);

Si solo tienes una CryptoKey de firma privada (incluso con extractable: false) y necesitas su CryptoKey pública correspondiente, llama a crypto.subtle.getPublicKey() con ['verify']:

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

3. Firmar y verificar cargas útiles

Llama a crypto.subtle.sign() con la clave privada para producir una firma y a crypto.subtle.verify() con la clave pública para verificarla:

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,
);

Separación de dominios con ContextParams

Para evitar que una firma creada con un propósito se vuelva a reproducir en otra parte de tu aplicación o protocolo que comparta el mismo par de claves, vincula la firma a un context de separación de dominios.

En lugar de una cadena de nombre de algoritmo simple, pasa un diccionario ContextParams ({ name, context }) a sign() y verify(), donde context es un BufferSource (Uint8Array o 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,
);
  • Límite de 255 bytes: context acepta de 0 a 255 bytes (omitir context equivale a pasar un new Uint8Array(0) vacío). Si se pasan más de 255 bytes, se rechaza con OperationError.
  • Se requiere una coincidencia exacta: crypto.subtle.verify() devuelve true solo cuando el verificador pasa exactamente los mismos bytes de context que se usaron durante crypto.subtle.sign().

Estrategias de resguardo

Si tu objetivo de Baseline aún no admite SubtleCrypto.supports() o ML-DSA, detecta la compatibilidad con el método SubtleCrypto.supports() estático y recurre al 'Ed25519' clásico (o 'ECDSA' con 'P-256' y 'SHA-256' cuando se orienta a navegadores anteriores a la compatibilidad con 'Ed25519'):

  • Llama a SubtleCrypto.supports() en el constructor SubtleCrypto, no en crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') es un método estático síncrono que devuelve un valor booleano (crypto.subtle.supports es undefined). Prueba 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' o 'getPublicKey' (o pasa un diccionario ContextParams como SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Formato de clave en el resguardo ('raw'): Si bien los navegadores que admiten WebCrypto moderno también aceptan 'raw-public' en 'Ed25519' y 'ECDSA', usa 'raw' en las rutas de resguardo orientadas a navegadores más antiguos.
  • Separación de dominios en el resguardo: Los algoritmos clásicos de WebCrypto ('Ed25519' y 'ECDSA') no leen una propiedad context del diccionario de algoritmos (WebIDL ignora de forma silenciosa las propiedades de diccionario desconocidas en Algorithm y EcdsaParams). Agrega un encabezado context con prefijo de longitud a la carga útil en tu ruta de resguardo para que la separación de dominios se comporte de manera coherente en ambos conjuntos.
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);
}