Criptografar dados do lado do cliente com a WebCrypto

Recursos da Web: Criptografia da Web

Use a API Web Cryptography (crypto.subtle) para estabelecer secrets compartilhados em redes não confiáveis usando mecanismos de encapsulamento de chaves (KEMs, na sigla em inglês) resistentes à computação quântica ou acordo de chaves clássico e criptografe dados sensíveis no navegador com criptografia autenticada com dados associados (AEAD, na sigla em inglês). Para assinar e verificar mensagens ou artefatos sem criptografá-los, consulte Assinar e verificar dados com a WebCrypto.

Um fluxo de criptografia de envelope de ponta a ponta é dividido em duas fases:

  • Configuração única do destinatário (etapas 1 e 2): o destinatário gera um par de chaves, mantém o CryptoKeyPair no IndexedDB para sessões futuras e exporta a chave pública ('raw-public') para compartilhar com os remetentes.
  • Criptografia por mensagem (etapas 3 e 4): para cada payload, o remetente importa a chave pública do destinatário, chama encapsulateKey() para gerar uma nova chave compartilhada única e uma cápsula de texto criptografado, criptografa o payload com um novo IV de 12 bytes e envia { capsule, iv, ciphertext } para o destinatário.

1. Escolher um algoritmo KEM e gerar um par de chaves do destinatário

Ao contrário do acordo de chaves Diffie-Hellman (deriveKey()), em que as duas partes combinam as chaves, um mecanismo de encapsulamento de chaves (KEM, na sigla em inglês) usa a chave pública do destinatário para gerar um segredo compartilhado aleatório e uma cápsula de texto criptografado em uma única chamada encapsulateKey(). O destinatário recupera a mesma chave compartilhada da cápsula de texto criptografado usando decapsulateKey().

Use 'MLKEM768-X25519' por padrão: ele combina o ML-KEM pós-quântico (padronizado no NIST FIPS 203) com o 'X25519' clássico para que o secret compartilhado permaneça seguro enquanto um dos algoritmos for válido. Escolha o 'ML-KEM-768' independente quando uma política exigir uma saída pura do FIPS 203 sem um componente híbrido ou 'ML-KEM-1024' quando uma política exigir conformidade com a Categoria 5 do NIST (CNSA 2.0).

Nome do algoritmo Nível de segurança e caso de uso Chave pública ('raw-public') Semente privada ('raw-seed') Tamanho do texto criptografado
'MLKEM768-X25519' Pós-quântico híbrido ('ML-KEM-768') + clássico ('X25519'); padrão recomendado 1.216 bytes 32 bytes. 1.120 bytes
'ML-KEM-768' Categoria 3 do NIST (segurança de ~192 bits); KEM pós-quântico independente 1.184 bytes 64 bytes 1.088 bytes
'ML-KEM-1024' Categoria 5 do NIST (segurança de ~256 bits); maior margem de segurança 1.568 bytes 64 bytes 1.568 bytes
  • Transmita 'MLKEM768-X25519', 'ML-KEM-768' ou 'ML-KEM-1024' como o nome do algoritmo (aliases como 'X-Wing' ou 'X25519MLKEM768', assim como 'ML-KEM-512', geram NotSupportedError).
  • Transmita extractable: false e ['encapsulateKey', 'decapsulateKey'] (ou ['encapsulateBits', 'decapsulateBits']) ao gerar um par de chaves KEM. Os objetos CryptoKey e CryptoKeyPair não extraíveis podem ser mantidos entre sessões diretamente no IndexedDB por meio de clonagem estruturada (objectStore.put(recipientKeyPair, 'recipient-kem-key')) sem exportar os bytes da chave privada:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. Exportar e importar chaves

Os algoritmos modernos da WebCrypto ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' e 'ChaCha20-Poly1305') rejeitam o formato legado 'raw' em importKey() e exportKey() com um NotSupportedError. Sempre especifique o formato de chave explícito:

  • 'raw-public': bytes de chave pública (1216, 1184 ou 1568 bytes). Transmita ['encapsulateKey'] (ou ['encapsulateBits']) ao importar uma chave pública KEM.
  • 'raw-seed': bytes de semente da chave privada (32 bytes para 'MLKEM768-X25519'; 64 bytes para 'ML-KEM-768' e 'ML-KEM-1024'). Transmita ['decapsulateKey'] (ou ['decapsulateBits']) ao importar uma chave privada KEM (transmitir ['encapsulateKey', 'decapsulateKey'] para importKey() ou getPublicKey() gera SyntaxError). 'raw' e 'raw-private' geram NotSupportedError em chaves privadas KEM. O 'MLKEM768-X25519' só é compatível com 'raw-public', 'raw-seed' e 'jwk'. Ele não é compatível com 'spki' ou 'pkcs8'.
  • 'raw-secret': bytes de chave secreta simétrica ('ChaCha20-Poly1305' e aceitos em 'AES-GCM' e 'HKDF').

Exporte a chave pública do destinatário com 'raw-public' para que os bytes da chave pública possam ser serializados (por exemplo, como Base64 via new Uint8Array(recipientPublicKeyBytes).toBase64() ou hexadecimal) e compartilhados com os remetentes:

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

Se você tiver apenas uma CryptoKey particular (mesmo com extractable: false) e precisar da CryptoKey pública correspondente, chame crypto.subtle.getPublicKey():

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

3. Encapsular e desencapsular uma chave compartilhada

encapsulateKey() e decapsulateKey() importam a chave secreta compartilhada KEM de 32 bytes (256 bits) para um CryptoKey usando o algoritmo especificado em sharedKeyAlgorithm (com o formato 'raw-secret'). Transmita 'ChaCha20-Poly1305' ou 'AES-GCM' diretamente para produzir uma chave de criptografia 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'],
);

Separação de domínios com 'HKDF'

Para vincular a chave compartilhada a um contexto de protocolo específico (info e salt) ou derivar várias subchaves de um único encapsulamento, transmita 'HKDF' como sharedKeyAlgorithm com o uso de ['deriveKey'] e chame 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. Criptografar e descriptografar payloads ('ChaCha20-Poly1305' e 'AES-GCM')

Criptografe os dados do payload com um algoritmo AEAD ('ChaCha20-Poly1305' ou 'AES-GCM') usando um vetor de inicialização (iv) de 12 bytes (96 bits) novo e criptograficamente aleatório para cada operação de criptografia. Nunca reutilize um par (iv, key). Escolha 'ChaCha20-Poly1305' para um desempenho consistente em tempo constante em dispositivos sem aceleração de hardware AES dedicada ou 'AES-GCM' ao interoperar com implantações atuais do WebCrypto ou do FIPS 140.

  • 'ChaCha20-Poly1305': usa uma chave fixa de 256 bits (32 bytes). generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) usa uma string ou { name: 'ChaCha20-Poly1305' } sem a propriedade length. O iv precisa ter exatamente 12 bytes, e a tag de autenticação Poly1305 de 128 bits é anexada ao texto criptografado.
  • 'AES-GCM': transmita { name: 'AES-GCM', length: 256 } ao chamar generateKey() ou deriveKey() e um iv de 12 bytes ao chamar encrypt() e decrypt().
  • additionalData (AAD): vincule metadados não criptografados (como um ID de registro, ID de destinatário ou versão de protocolo) à tag de autenticação usando additionalData para que a adulteração do texto criptografado ou dos metadados faça com que decrypt() rejeite com 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);
}

Estratégias de fallback

Se o destino de referência ainda não for compatível com SubtleCrypto.supports(), encapsulateKey() ou 'ChaCha20-Poly1305', detecte a compatibilidade usando o método estático SubtleCrypto.supports() e use o acordo de chave Diffie-Hellman estático-temporário ('X25519' + 'HKDF' + 'AES-GCM' ou 'ECDH' com 'P-256' ao segmentar navegadores anteriores à compatibilidade com 'X25519'):

  • Chame SubtleCrypto.supports() no construtor SubtleCrypto, não em crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') é um método estático síncrono que retorna um booleano (crypto.subtle.supports é undefined). O método SubtleCrypto.supports() valida parâmetros específicos da operação: strings de algoritmo simples funcionam para 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey' e 'getPublicKey', enquanto a sondagem de 'encrypt' ou 'decrypt' requer diretamente um iv (por exemplo, SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • Formato de chave no substituto ('raw'): embora os navegadores compatíveis com o Modern WebCrypto também aceitem 'raw-public' em 'X25519' e 'ECDH', use 'raw' em caminhos substitutos destinados a navegadores mais antigos.
  • Fallback clássico ('X25519' + 'HKDF' + 'AES-GCM'): quando SubtleCrypto.supports() não estiver disponível ou retornar false, gere um par de chaves 'X25519' temporário para o remetente, exporte a chave pública temporária como os bytes da cápsula, derive 256 bits usando deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) e transmita esses bits por 'HKDF' ('SHA-256') para derivar uma chave '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);
}