Assinar e verificar dados com a WebCrypto

Recursos da Web: Criptografia da Web

Use a API Web Cryptography (crypto.subtle) para assinar e verificar mensagens, tokens ou artefatos no navegador usando chaves assimétricas resistentes a ataques quânticos (ML-DSA) ou clássicas ('Ed25519', 'ECDSA'). As assinaturas digitais garantem a integridade de dados e a autenticidade do signatário sem exigir chaves secretas compartilhadas. Para estabelecer secrets compartilhados e criptografar payloads confidenciais, consulte Criptografar dados do lado do cliente com o WebCrypto.

1. Escolher um algoritmo e gerar um par de chaves

O ML-DSA (algoritmo de assinatura digital baseado em rede de módulos, padronizado no NIST FIPS 204) fornece assinaturas digitais pós-quânticas baseadas em rede resistentes a ataques de computadores clássicos e quânticos.

Use 'ML-DSA-65' por padrão para assinatura de uso geral. Escolha 'ML-DSA-44' quando precisar de chaves públicas e assinaturas menores em canais com largura de banda limitada e a segurança de 128 bits for suficiente, ou 'ML-DSA-87' quando uma política de segurança exigir requisitos da política da Categoria 5 do NIST (CNSA 2.0).

Nome do algoritmo Nível de segurança e caso de uso Chave pública ('raw-public') Tamanho da assinatura
'ML-DSA-44' Categoria 2 do NIST (segurança de ~128 bits); chaves e assinaturas menores 1.312 bytes 2.420 bytes
'ML-DSA-65' Categoria 3 do NIST (segurança de ~192 bits); padrão recomendado 1.952 bytes 3309 bytes
'ML-DSA-87' Categoria 5 do NIST (segurança de ~256 bits); maior margem de segurança 2.592 bytes 4.627 bytes
  • Transmita 'ML-DSA-44', 'ML-DSA-65' ou 'ML-DSA-87' como o nome do algoritmo (nomes pré-padronizados, como 'Dilithium' ou 'CRYSTALS-Dilithium', geram NotSupportedError).
  • Nem o ML-DSA nem o 'Ed25519' usam um parâmetro hash.
  • Transmita ['sign', 'verify'] ao gerar um par de chaves com generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Exportar e importar chaves

'ML-DSA-44', 'ML-DSA-65' e 'ML-DSA-87' rejeitam o formato legado 'raw' em importKey() e exportKey() com um NotSupportedError. Sempre exporte ou importe chaves usando um formato explícito:

  • 'raw-public': bytes de chave pública (1312, 1952 ou 2592 bytes). Transmita ['verify'] como o uso da chave ao importar uma chave pública.
  • 'raw-seed': semente de chave privada de 32 bytes para os três algoritmos ML-DSA. Tanto 'raw' quanto 'raw-private' geram NotSupportedError no ML-DSA. Armazene ou transmita chaves privadas usando 'raw-seed', 'pkcs8' (54 bytes) ou 'jwk' (kty: 'AKP'). Transmita ['sign'] como o uso da chave ao importar uma chave privada (transmitir ['sign', 'verify'] para importKey() gera SyntaxError).
  • 'spki', 'pkcs8' e 'jwk': compatíveis com os três algoritmos de ML-DSA.

Sempre exporte a chave pública de verificação com 'raw-public' para que os bytes da chave pública possam ser serializados ou compartilhados com os 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'],
);

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

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

3. Assinar e verificar payloads

Chame crypto.subtle.sign() com a chave privada para produzir uma assinatura e crypto.subtle.verify() com a chave pública para verificá-la:

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

Separação de domínios com ContextParams

Para evitar que uma assinatura criada para uma finalidade seja reproduzida em outra parte do aplicativo ou protocolo que compartilha o mesmo par de chaves, vincule a assinatura a um context de separação de domínio.

Em vez de uma string de nome de algoritmo simples, transmita um dicionário ContextParams ({ name, context }) para sign() e verify(), em que context é um BufferSource (Uint8Array ou 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,
);
  • Limite de 255 bytes: context aceita de 0 a 255 bytes (omitir context equivale a transmitir um new Uint8Array(0) vazio). Transmitir mais de 255 bytes rejeita com OperationError.
  • Correspondência exata necessária: crypto.subtle.verify() retorna true somente quando o verificador transmite os mesmos bytes context usados durante crypto.subtle.sign().

Estratégias de fallback

Se o destino do Baseline ainda não for compatível com SubtleCrypto.supports() ou ML-DSA, detecte a compatibilidade usando o método estático SubtleCrypto.supports() e use o 'Ed25519' clássico (ou 'ECDSA' com 'P-256' e 'SHA-256' ao segmentar navegadores anteriores à compatibilidade com 'Ed25519'):

  • Chame SubtleCrypto.supports() no construtor SubtleCrypto, não em crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') é um método estático síncrono que retorna um booleano (crypto.subtle.supports é undefined). Verifique 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' ou 'getPublicKey' (ou transmita um dicionário ContextParams, como SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Formato de chave no substituto ('raw'): embora os navegadores compatíveis com o Modern WebCrypto também aceitem 'raw-public' em 'Ed25519' e 'ECDSA', use 'raw' em caminhos substitutos destinados a navegadores mais antigos.
  • Separação de domínio no substituto: os algoritmos clássicos da WebCrypto ('Ed25519' e 'ECDSA') não leem uma propriedade context do dicionário de algoritmos (o WebIDL ignora silenciosamente as propriedades de dicionário desconhecidas em Algorithm e EcdsaParams). Adicione um cabeçalho context com prefixo de comprimento ao payload no caminho substituto para que a separação de domínio se comporte de maneira consistente em ambos os pacotes.
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);
}