Encripta datos del cliente con WebCrypto

Funciones web: Criptografía web

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 CryptoKeyPair en 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', arrojan NotSupportedError).
  • Pasa extractable: false y ['encapsulateKey', 'decapsulateKey'] (o ['encapsulateBits', 'decapsulateBits']) cuando generes un par de claves de KEM. Los objetos CryptoKey y CryptoKeyPair no 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, 1184 o 1568 bytes). Pasa ['encapsulateKey'] (o ['encapsulateBits']) cuando importes una clave pública de KEM.
  • 'raw-seed': Bytes de origen de la clave privada (32 bytes para 'MLKEM768-X25519'; 64 bytes para 'ML-KEM-768' y 'ML-KEM-1024'). Pasa ['decapsulateKey'] (o ['decapsulateBits']) cuando importes una clave privada de KEM (pasar ['encapsulateKey', 'decapsulateKey'] a importKey() o getPublicKey() arroja SyntaxError). Tanto 'raw' como 'raw-private' arrojan NotSupportedError en 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 propiedad length). El iv debe 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 a generateKey() o deriveKey(), y un iv de 12 bytes cuando llames a encrypt() y decrypt().
  • 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 de additionalData para que la manipulación del texto cifrado o de los metadatos haga que decrypt() rechace con OperationError.
/**
 * 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 constructor SubtleCrypto, no en crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') es un método estático síncrono que devuelve un valor booleano (crypto.subtle.supports es undefined). Ten en cuenta que SubtleCrypto.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 un iv (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'): Cuando SubtleCrypto.supports() no está disponible o devuelve false, 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 de deriveBits({ 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);
}