Usa la API de Web Cryptography (crypto.subtle) para establecer secretos compartidos en redes no confiables con mecanismos de encapsulamiento de claves (KEM) resistentes a la computación cuántica o acuerdos de claves clásicos, y encripta datos sensibles en el navegador con la encriptación autenticada con datos asociados (AEAD). Para firmar y verificar mensajes o artefactos sin encriptarlos, consulta Firma y verifica datos con WebCrypto.
Un flujo de encriptación de sobre de extremo a extremo se divide en dos fases:
- Configuración única del destinatario (pasos 1 y 2): El destinatario genera un par de claves, conserva la
CryptoKeyPairen IndexedDB para sesiones futuras y exporta la clave pública ('raw-public') para compartirla con los remitentes. - Encriptación por mensaje (pasos 3 y 4): Para cada carga útil, el remitente importa la clave pública del destinatario, llama a
encapsulateKey()para generar una nueva clave compartida de un solo uso y una cápsula de texto cifrado, encripta la carga útil con un IV nuevo de 12 bytes y envía{ capsule, iv, ciphertext }al destinatario.
1. Elige un algoritmo de KEM y genera un par de claves de destinatario
A diferencia del acuerdo de claves de Diffie-Hellman (deriveKey()), en el que ambas partes combinan sus claves, un mecanismo de encapsulamiento de claves (KEM) usa la clave pública del destinatario para generar un secreto compartido aleatorio y una cápsula de texto cifrado en una sola llamada a encapsulateKey(). El destinatario recupera la misma clave compartida de la cápsula de texto cifrado con decapsulateKey().
Usa 'MLKEM768-X25519' de forma predeterminada: combina el ML-KEM poscuántico (estandarizado en NIST FIPS 203) con el 'X25519' clásico para que el secreto compartido siga siendo seguro mientras se mantenga cualquiera de los algoritmos. Elige 'ML-KEM-768' independiente cuando una política requiera un resultado de FIPS 203 puro sin un componente híbrido, o 'ML-KEM-1024' cuando una política 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') |
Valor de origen privado ('raw-seed') |
Tamaño del texto cifrado |
|---|---|---|---|---|
'MLKEM768-X25519' |
Híbrido poscuántico ('ML-KEM-768') + clásico ('X25519'); opción predeterminada recomendada |
1,216 bytes | 32 bytes | 1,120 bytes |
'ML-KEM-768' |
Categoría 3 del NIST (seguridad de ~192 bits); KEM poscuántico independiente | 1184 bytes | 64 bytes | 1088 bytes |
'ML-KEM-1024' |
Categoría 5 del NIST (seguridad de ~256 bits); margen de seguridad más alto | 1,568 bytes | 64 bytes | 1,568 bytes |
- Pasa
'MLKEM768-X25519','ML-KEM-768'o'ML-KEM-1024'como el nombre del algoritmo (los alias como'X-Wing'o'X25519MLKEM768', así como'ML-KEM-512', arrojanNotSupportedError). - Pasa
extractable: falsey['encapsulateKey', 'decapsulateKey'](o['encapsulateBits', 'decapsulateBits']) cuando generes un par de claves de KEM. Los objetosCryptoKeyyCryptoKeyPairno extraíbles se pueden conservar entre sesiones directamente en IndexedDB a través de la clonación estructurada (objectStore.put(recipientKeyPair, 'recipient-kem-key')) sin exportar los bytes de la clave privada:
const recipientKeyPair = await crypto.subtle.generateKey(
'MLKEM768-X25519',
false,
['encapsulateKey', 'decapsulateKey'],
);
2. Importa y exporta claves
Los algoritmos modernos de WebCrypto ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' y 'ChaCha20-Poly1305') rechazan el formato heredado 'raw' en importKey() y exportKey() con un NotSupportedError. Especifica siempre el formato de clave explícito:
'raw-public': Bytes de clave pública (1216,1184o1568bytes). Pasa['encapsulateKey'](o['encapsulateBits']) cuando importes una clave pública de KEM.'raw-seed': Bytes de origen de la clave privada (32bytes para'MLKEM768-X25519';64bytes para'ML-KEM-768'y'ML-KEM-1024'). Pasa['decapsulateKey'](o['decapsulateBits']) cuando importes una clave privada de KEM (pasar['encapsulateKey', 'decapsulateKey']aimportKey()ogetPublicKey()arrojaSyntaxError). Tanto'raw'como'raw-private'arrojanNotSupportedErroren las claves privadas de KEM. Ten en cuenta que'MLKEM768-X25519'solo admite'raw-public','raw-seed'y'jwk', no admite'spki'ni'pkcs8'.'raw-secret': Bytes de clave secreta simétrica ('ChaCha20-Poly1305'y aceptados en'AES-GCM'y'HKDF').
Exporta la clave pública del destinatario con 'raw-public' para que los bytes de la clave pública se puedan serializar (por ejemplo, como Base64 a través de new Uint8Array(recipientPublicKeyBytes).toBase64() o hexadecimal) y compartir con los remitentes:
// Export the recipient's public key bytes
const recipientPublicKeyBytes = await crypto.subtle.exportKey(
'raw-public',
recipientKeyPair.publicKey,
);
// Import the recipient's public key on the sender (usage must be ['encapsulateKey'])
const importedPublicKey = await crypto.subtle.importKey(
'raw-public',
recipientPublicKeyBytes,
'MLKEM768-X25519',
true,
['encapsulateKey'],
);
Si solo tienes un CryptoKey privado (incluso con extractable: false) y necesitas su CryptoKey público correspondiente, llama a crypto.subtle.getPublicKey():
const publicKey = await crypto.subtle.getPublicKey(
recipientKeyPair.privateKey,
['encapsulateKey'],
);
3. Encapsula y desencapsula una clave compartida
encapsulateKey() y decapsulateKey() importan el secreto compartido de KEM de 32 bytes (256 bits) en un CryptoKey con el algoritmo especificado en sharedKeyAlgorithm (con el formato 'raw-secret'). Pasa 'ChaCha20-Poly1305' o 'AES-GCM' directamente para producir una clave de encriptación AEAD:
// Sender encapsulates a 256-bit AEAD key and produces a ciphertext capsule
const { sharedKey: senderKey, ciphertext: encapsulatedCiphertext } =
await crypto.subtle.encapsulateKey(
'MLKEM768-X25519',
importedPublicKey,
'ChaCha20-Poly1305', // Or 'AES-GCM'
false,
['encrypt', 'decrypt'],
);
// Recipient decapsulates the ciphertext capsule to recover the identical AEAD key
const recipientKey = await crypto.subtle.decapsulateKey(
'MLKEM768-X25519',
recipientKeyPair.privateKey,
encapsulatedCiphertext,
'ChaCha20-Poly1305',
false,
['encrypt', 'decrypt'],
);
Separación de dominios con 'HKDF'
Para vincular la clave compartida a un contexto de protocolo específico (info y salt) o derivar varias subclaves de una sola encapsulación, pasa 'HKDF' como sharedKeyAlgorithm con el uso de ['deriveKey'] y, luego, llama a crypto.subtle.deriveKey():
const { sharedKey: hkdfBaseKey, ciphertext } = await crypto.subtle.encapsulateKey(
'MLKEM768-X25519',
importedPublicKey,
'HKDF',
false,
['deriveKey'],
);
const aeadKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(32),
info: new TextEncoder().encode('envelope-v1'),
},
hkdfBaseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
4. Encriptar y desencriptar cargas útiles ('ChaCha20-Poly1305' y 'AES-GCM')
Encripta los datos de la carga útil con un algoritmo AEAD ('ChaCha20-Poly1305' o 'AES-GCM') usando un vector de inicialización (iv) de 12 bytes (96 bits) nuevo y criptográficamente aleatorio para cada operación de encriptación. Nunca reutilices un par de (iv, key). Elige 'ChaCha20-Poly1305' para obtener un rendimiento constante en todos los dispositivos sin aceleración de hardware AES dedicada, o 'AES-GCM' cuando interoperes con implementaciones existentes de WebCrypto o FIPS 140.
'ChaCha20-Poly1305': Usa una clave fija de 256 bits (32 bytes) (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt'])toma una cadena o{ name: 'ChaCha20-Poly1305' }sin la propiedadlength). Elivdebe tener exactamente 12 bytes, y la etiqueta de autenticación Poly1305 de 128 bits se agrega al texto cifrado.'AES-GCM': Pasa{ name: 'AES-GCM', length: 256 }cuando llames agenerateKey()oderiveKey(), y univde 12 bytes cuando llames aencrypt()ydecrypt().additionalData(AAD): Vincula metadatos sin encriptar (como un ID de registro, un ID de destinatario o una versión de protocolo) a la etiqueta de autenticación a través deadditionalDatapara que la manipulación del texto cifrado o de los metadatos haga quedecrypt()rechace conOperationError.
/**
* Encrypts UTF-8 text with an AEAD CryptoKey ('ChaCha20-Poly1305' or 'AES-GCM').
* @param {CryptoKey} key
* @param {string} plaintext
* @param {Uint8Array} [additionalData]
*/
export async function encryptMessage(key, plaintext, additionalData = new Uint8Array(0)) {
// Always generate a fresh 12-byte (96-bit) IV per message
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertextBuffer = await crypto.subtle.encrypt(
{
name: key.algorithm.name,
iv,
additionalData,
},
key,
encoded,
);
return { iv, ciphertext: new Uint8Array(ciphertextBuffer) };
}
/**
* Decrypts and authenticates an AEAD payload. Throws OperationError if tampered.
* @param {CryptoKey} key
* @param {Uint8Array} iv
* @param {Uint8Array} ciphertext
* @param {Uint8Array} [additionalData]
*/
export async function decryptMessage(key, iv, ciphertext, additionalData = new Uint8Array(0)) {
const plaintextBuffer = await crypto.subtle.decrypt(
{
name: key.algorithm.name,
iv,
additionalData,
},
key,
ciphertext,
);
return new TextDecoder().decode(plaintextBuffer);
}
Estrategias de resguardo
Si tu objetivo de Baseline aún no admite SubtleCrypto.supports(), encapsulateKey() o 'ChaCha20-Poly1305', detecta la compatibilidad con el método estático SubtleCrypto.supports() y recurre al acuerdo de claves Diffie-Hellman estático efímero ('X25519' + 'HKDF' + 'AES-GCM' o 'ECDH' con 'P-256' cuando se orienta a navegadores anteriores a la compatibilidad con 'X25519'):
- Llama a
SubtleCrypto.supports()en el constructorSubtleCrypto, no encrypto.subtle:globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')es un método estático síncrono que devuelve un valor booleano (crypto.subtle.supportsesundefined). Ten en cuenta queSubtleCrypto.supports()valida parámetros específicos de la operación: las cadenas de algoritmos simples funcionan para'encapsulateKey','decapsulateKey','generateKey','importKey','exportKey'y'getPublicKey', mientras que el sondeo de'encrypt'o'decrypt'requiere directamente univ(por ejemplo,SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })). - Formato de clave en el resguardo (
'raw'): Si bien los navegadores que admiten WebCrypto moderno también aceptan'raw-public'en'X25519'y'ECDH', usa'raw'en las rutas de resguardo orientadas a navegadores más antiguos. - Resguardo clásico (
'X25519'+'HKDF'+'AES-GCM'): CuandoSubtleCrypto.supports()no está disponible o devuelvefalse, genera un par de claves'X25519'efímeras para el remitente, exporta la clave pública efímera como los bytes de la cápsula, deriva 256 bits a través dederiveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256)y pasa esos bits a través de'HKDF'('SHA-256') para derivar una clave'AES-GCM'de 256 bits.
const KEM_PUBLIC_KEY_ALGORITHMS = {
1184: 'ML-KEM-768',
1216: 'MLKEM768-X25519',
1568: 'ML-KEM-1024',
};
export function supportsPostQuantumEnvelope() {
return (
typeof globalThis.SubtleCrypto?.supports === 'function' &&
SubtleCrypto.supports('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')
);
}
export async function generateRecipientKeys() {
if (supportsPostQuantumEnvelope()) {
const keyPair = await crypto.subtle.generateKey(
'MLKEM768-X25519',
false,
['encapsulateKey', 'decapsulateKey'],
);
const publicKeyBytes = new Uint8Array(
await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
);
return { suite: 'MLKEM768-X25519+ChaCha20-Poly1305', keyPair, publicKeyBytes };
}
// Classical fallback: X25519 key agreement + HKDF + AES-GCM
const keyPair = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
const publicKeyBytes = new Uint8Array(
await crypto.subtle.exportKey('raw', keyPair.publicKey),
);
return { suite: 'X25519+HKDF+AES-GCM', keyPair, publicKeyBytes };
}
async function deriveFallbackAesKey(privateKey, peerPublicKey, infoBytes = new Uint8Array(0)) {
const sharedSecret = await crypto.subtle.deriveBits(
{ name: 'X25519', public: peerPublicKey },
privateKey,
256,
);
const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(32),
info: infoBytes,
},
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
export async function encapsulateEnvelopeKey(
recipientPublicKeyOrBytes,
infoBytes = new Uint8Array(0),
) {
const pubKey =
recipientPublicKeyOrBytes instanceof CryptoKey
? recipientPublicKeyOrBytes
: await (() => {
const bytes =
recipientPublicKeyOrBytes instanceof Uint8Array
? recipientPublicKeyOrBytes
: new Uint8Array(recipientPublicKeyOrBytes);
const kemName = KEM_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
return kemName
? crypto.subtle.importKey('raw-public', bytes, kemName, true, ['encapsulateKey'])
: crypto.subtle.importKey('raw', bytes, 'X25519', true, []);
})();
if (pubKey.algorithm.name !== 'X25519') {
const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
pubKey.algorithm.name,
pubKey,
'ChaCha20-Poly1305',
false,
['encrypt', 'decrypt'],
);
return { sharedKey, capsuleBytes: new Uint8Array(ciphertext) };
}
// Classical fallback: generate ephemeral X25519 key pair and send ephemeral public key as capsule
const ephemeral = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
const capsuleBytes = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
const sharedKey = await deriveFallbackAesKey(ephemeral.privateKey, pubKey, infoBytes);
return { sharedKey, capsuleBytes };
}
export async function decapsulateEnvelopeKey(
recipientPrivateKey,
capsuleBytes,
infoBytes = new Uint8Array(0),
) {
if (recipientPrivateKey.algorithm.name !== 'X25519') {
return crypto.subtle.decapsulateKey(
recipientPrivateKey.algorithm.name,
recipientPrivateKey,
capsuleBytes,
'ChaCha20-Poly1305',
false,
['encrypt', 'decrypt'],
);
}
const ephemeralPubKey = await crypto.subtle.importKey('raw', capsuleBytes, 'X25519', true, []);
return deriveFallbackAesKey(recipientPrivateKey, ephemeralPubKey, infoBytes);
}