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', arrojanNotSupportedError). - Ni ML-DSA ni
'Ed25519'toman un parámetrohash. - Pasa
['sign', 'verify']cuando generes un par de claves congenerateKey():
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,1952o2592bytes). 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'arrojanNotSupportedErroren 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']aimportKey()arrojaSyntaxError).'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:
contextacepta de0a255bytes (omitircontextequivale a pasar unnew Uint8Array(0)vacío). Si se pasan más de255bytes, se rechaza conOperationError. - Se requiere una coincidencia exacta:
crypto.subtle.verify()devuelvetruesolo cuando el verificador pasa exactamente los mismos bytes decontextque se usaron durantecrypto.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 constructorSubtleCrypto, no encrypto.subtle:globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65')es un método estático síncrono que devuelve un valor booleano (crypto.subtle.supportsesundefined). Prueba'sign','verify','generateKey','importKey','exportKey'o'getPublicKey'(o pasa un diccionarioContextParamscomoSubtleCrypto.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 propiedadcontextdel diccionario de algoritmos (WebIDL ignora de forma silenciosa las propiedades de diccionario desconocidas enAlgorithmyEcdsaParams). Agrega un encabezadocontextcon 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);
}