Pular para o conteúdo
ecdsa.com
← Todos os artigos

Assinatura com KMS em nuvem: AWS, Google Cloud e Azure

A chave privada nunca sai do serviço, então cada peculiaridade da API de assinatura vira problema seu: o que você envia, qual é o hash e qual codificação volta. Conferimos a documentação de cada provedor em 20 de outubro de 2026 e reproduzimos localmente cada armadilha de formato.

Resposta curta. Os três serviços mantêm a chave privada em seus HSMs ou módulos de software e assinam o que você envia. As diferenças que quebram integrações são pequenas e específicas. O AWS KMS e o Google Cloud KMS retornam assinaturas ECDSA codificadas em DER; um JWT precisa da forma r ‖ s de 64 bytes, então converta antes de codificar em base64url. O Azure Key Vault já retorna r ‖ s. Envie um digest para qualquer coisa maior que alguns kilobytes e informe ao serviço que se trata de um digest (MessageType: DIGEST na AWS, o campo digest no Google Cloud; o Azure sempre espera um), senão o hash será calculado duas vezes. Dê a quem chama o serviço uma permissão só de assinatura, restrita a uma chave, ative os logs de auditoria que vêm desativados por padrão (os logs de acesso a dados do Google Cloud, as configurações de diagnóstico do Azure) e verifique as assinaturas offline com a chave pública em vez de chamar o serviço.

As chamadas aos serviços de nuvem desta página não puderam ser executadas em um contêiner. O código delas passou pela verificação de tipos com tsc --strict contra as versões fixadas dos SDKs, e cada problema de formato foi reproduzido com uma chave local que se comporta como o serviço: mesmo hash, mesma codificação de saída.

Os três serviços lado a lado

AWS KMSGoogle Cloud KMSAzure Key Vault
Chamada de assinaturaSignAsymmetricSignsign (REST keys/{name}/{version}/sign)
Entradamensagem de até 4096 bytes ou digest (MessageType RAW, DIGEST, EXTERNAL_MU)digest ou data, nunca os doissempre um digest
Saída ECDSADER (RFC 3279)DERr ‖ s (formato da RFC 7518)
Chaves ECP-256, P-384, P-521, secp256k1P-256, P-384, secp256k1 (só HSM)P-256, P-256K (secp256k1), P-384, P-521
Ed25519sim, puro e com pré-hashsim, puro (só chaves de software)não
RSA2048, 3072, 4096; PSS e PKCS #1 v1.52048, 3072, 4096; PSS, PKCS #1 v1.5, PKCS #1 bruto2048, 3072, 4096; PSS e PKCS #1 v1.5
Pós-quânticoML-DSA-44/65/87ML-DSA-44/65/87, SLH-DSA-SHA2-128s (chaves de software)nenhum
Proteção da chaveHSMs FIPS 140-3 Nível 3software (primitivas FIPS 140-3 L1) ou Cloud HSM (FIPS 140-2 L3)software (FIPS 140-2 L1) ou HSM (FIPS 140-3 L3 na plataforma de HSM 2, Premium)

Fontes: AWS, referência de especificações de chave e Sign; Google Cloud, algoritmos e níveis de proteção; Azure, tipos de chave e algoritmos. Dois detalhes dessas páginas importam mais adiante: para a secp256k1, o Google Cloud KMS “gera assinaturas somente na forma ‘normalizada’ (também conhecida como ‘forma lower-S’)”, e o AWS KMS não documenta nenhuma garantia desse tipo.

Digest ou mensagem

As assinaturas ECDSA, RSA e ML-DSA começam calculando o hash da entrada. Um KMS pode fazer isso por você, mas a AWS aceita no máximo 4096 bytes de mensagem e o Azure aceita só digests, então na prática você calcula o hash localmente e envia o valor SHA-256 de 32 bytes. Nesse caso, o serviço precisa pular a própria etapa de hash, e você precisa avisá-lo disso:

  • AWS KMS: MessageType: "DIGEST". A referência da API avisa que enviar um digest como RAW “pode causar falhas de verificação ao verificar com um sistema que pressupõe um único hash”.
  • Google Cloud KMS: coloque o valor em digest.sha256 (ou sha384, conforme o algoritmo da chave). O campo data é obrigatório para algoritmos que recebem a entrada bruta, como o Ed25519.
  • Azure Key Vault: a operação é descrita como “cria uma assinatura a partir de um digest”; ES256 espera um valor SHA-256 de 32 bytes.

Veja como fica o erro do hash duplo, reproduzido com o OpenSSL 3.6.5 e uma chave P-256 local no lugar da chave do KMS:

files.sh (trecho): assinar um arquivo de 10 MB pelo digest e depois o erro do hash duplo
openssl dgst -sha256 -binary release.tar.gz > release.sha256
openssl pkeyutl -sign -inkey kms.key -in release.sha256 -out release.sig     # the KMS call
openssl dgst -sha256 -verify kms.pub -signature release.sig release.tar.gz

# The digest sent as if it were the message (MessageType RAW): hashed twice
openssl dgst -sha256 -sign kms.key -out double.sig release.sha256
openssl dgst -sha256 -verify kms.pub -signature double.sig release.tar.gz
openssl dgst -sha256 -verify kms.pub -signature double.sig release.sha256
Saída (debian:forky-20261005-slim, OpenSSL 3.6.5)
Verified OK
Verification failure
Verified OK

A segunda assinatura é válida, só que não sobre o arquivo: ela assina o arquivo do digest como se fosse a mensagem. Todo verificador padrão calcula o hash do arquivo uma vez, então a verificação falha. A correção é um único parâmetro, e é por isso que esse bug passa despercebido na revisão de código.

O Ed25519 tem a própria variante do problema. O AWS KMS oferece dois algoritmos para chaves ECC_NIST_EDWARDS25519: ED25519_SHA_512 (Ed25519 puro, exige MessageType: RAW, portanto no máximo 4096 bytes) e ED25519_PH_SHA_512 (o Ed25519ph da RFC 8032, exige DIGEST, e a AWS ainda aplica o próprio pré-hash SHA-512: “o hash da entrada é calculado duas vezes: uma por você e outra pelo KMS”). Os dois produzem assinaturas diferentes, e a assinatura de um não passa na verificação do outro:

openssl pkeyutl -verify -rawin, com e sem -pkeyopt instance:Ed25519ph
pure signature, verified as Ed25519          Signature Verified Successfully
Ed25519ph signature, verified as Ed25519     Signature Verification Failure
Ed25519ph signature, verified as Ed25519ph   Signature Verified Successfully

A maioria dos verificadores de Ed25519 (JOSE EdDSA, SSH, minisign) implementa apenas o Ed25519 puro. Use ED25519_SHA_512 com entradas pequenas, como a entrada de assinatura de um JWT, e, para arquivos, assine um manifesto que contenha o hash do arquivo.

Assinatura de JWTs: DER versus r ‖ s

Um JWS com alg: ES256 carrega uma assinatura de exatamente 64 bytes, com r e s preenchidos com zeros à esquerda até 32 bytes cada (RFC 7518 §3.4). Já a AWS e o Google Cloud retornam a estrutura DER SEQUENCE { INTEGER r, INTEGER s }. O laboratório assina um JWT com uma chave P-256 local que retorna DER, exatamente como esses serviços, e o verifica com o jose e o jsonwebtoken antes e depois da conversão:

jwt.mjs (trecho): conversão de DER para JWS (node 24.22.0, jose 6.2.13, jsonwebtoken 9.0.3)
/** DER ECDSA-Sig-Value (RFC 3279) -> fixed-size r || s (RFC 7518 §3.4). */
function derToJose(der, size = 32) {
  let i = 0;
  const take = (buf, tag) => {
    if (buf[i++] !== tag) throw new Error(`expected ASN.1 tag 0x${tag.toString(16)}`);
    let len = buf[i++];
    if (len & 0x80) { // long form length
      const n = len & 0x7f;
      len = 0;
      for (let k = 0; k < n; k++) len = (len << 8) | buf[i++];
    }
    const value = buf.subarray(i, i + len);
    i += len;
    return value;
  };
  const seq = take(der, 0x30);
  if (i !== der.length) throw new Error("trailing bytes after the signature");
  i = 0;
  const out = Buffer.alloc(2 * size);
  for (const offset of [0, size]) {
    let int = take(seq, 0x02);
    while (int.length > size && int[0] === 0) int = int.subarray(1); // drop the sign byte
    if (int.length > size) throw new Error("integer longer than the curve size");
    int.copy(out, offset + size - int.length); // left-pad short values with zeros
  }
  return out;
}

const b64u = (b) => Buffer.from(b).toString("base64url");
async function signJwt(payload, { convert }) {
  const input = `${b64u(JSON.stringify({ alg: "ES256", typ: "JWT", kid: "kms-key-1" }))}.${b64u(JSON.stringify(payload))}`;
  const der = await kmsSignDer(Buffer.from(input));
  return `${input}.${b64u(convert ? derToJose(der) : der)}`;
}
Saída (node:24.22.0-bookworm-slim)
node v24.22.0, OpenSSL 3.5.8, jose 6.2.13, jsonwebtoken 9.0.3
DER as returned  (72 bytes)  jose: ERR_JWS_SIGNATURE_VERIFICATION_FAILED | jsonwebtoken: TypeError: "ES256" signatures must be "64" bytes, saw "72"
converted to r||s (64 bytes)  jose: valid | jsonwebtoken: valid
DER lengths over 20000 signatures: {"68":1,"69":45,"70":4965,"71":9956,"72":5033}
r || s without left padding would not be 64 bytes in 172 of 20000 (about 1 in 116)

Aqui, kmsSignDer é o sign("sha256", data, privateKey) do node:crypto, que retorna DER como os serviços. Vale lembrar três coisas:

  • O jose rejeita o DER sem nenhuma pista sobre o formato; o jsonwebtoken aponta o problema: ES256 signatures must be 64 bytes. Os verificadores do Java dizem Invalid encoding for signature quando recebem o formato oposto.
  • O tamanho do DER varia de 68 a 72 bytes. Um código que corta em um deslocamento fixo, ou que pressupõe 70 bytes, funciona nos testes e falha em produção.
  • Cerca de uma assinatura em 128 tem um r ou um s menor que 32 bytes (172 de 20.000 nesta execução). Um conversor que esquece de preencher com zeros à esquerda produz uma assinatura de 63 bytes que alguns verificadores rejeitam e outros interpretam errado — o tipo de bug intermitente que aparece como “um login em cada cem falha”. O artigo formatos de assinatura ECDSA trata das codificações em detalhe.

No Azure Key Vault, os bytes de SignResult.result já são r ‖ s e vão direto para o JWS. Nesse caso, a conversão oposta é necessária para verificadores que esperam DER, como o SHA256withECDSA do Java ou o OpenSSL.

Convém que o kid identifique a versão da chave, e não só a chave, para que os verificadores consigam escolher a chave pública certa durante uma rotação. Publique a chave pública como JWK: o GetPublicKey da AWS e o GetPublicKey do Google Cloud retornam SPKI (DER ou PEM), e o Azure retorna uma JWK. A rotação sem quebrar os verificadores é o tema do artigo rotação de chaves JWKS.

Como chamar cada serviço

As três funções abaixo assinam o digest SHA-256 de um conjunto de dados e retornam os bytes que cada serviço devolve. Elas passaram pela verificação de tipos com tsc --strict (TypeScript 5.9.3) contra @aws-sdk/client-kms 3.1153.0, @google-cloud/kms 6.3.0, @azure/keyvault-keys 4.10.2 e @azure/identity 4.14.0; não foram executadas nos serviços, por isso nenhuma saída é mostrada.

kms.ts (com verificação de tipos, não executado nos serviços de nuvem)
// Signing a SHA-256 digest with each cloud's key service. Each returns the raw bytes the service gives back.
import { createHash } from "node:crypto";
import { GetPublicKeyCommand, KMSClient, SignCommand } from "@aws-sdk/client-kms";
import { KeyManagementServiceClient } from "@google-cloud/kms";
import { CryptographyClient } from "@azure/keyvault-keys";
import { DefaultAzureCredential } from "@azure/identity";

const sha256 = (data: Uint8Array) => createHash("sha256").update(data).digest();

// AWS KMS: ECC_NIST_P256 key, ECDSA_SHA_256. Returns DER (RFC 3279).
const aws = new KMSClient({});
export async function signWithAws(keyId: string, data: Uint8Array): Promise<Uint8Array> {
  const out = await aws.send(
    new SignCommand({ KeyId: keyId, Message: sha256(data), MessageType: "DIGEST", SigningAlgorithm: "ECDSA_SHA_256" }),
  );
  if (!out.Signature) throw new Error("AWS KMS returned no signature");
  return out.Signature;
}
export async function publicKeyFromAws(keyId: string): Promise<Uint8Array> {
  const out = await aws.send(new GetPublicKeyCommand({ KeyId: keyId }));
  if (!out.PublicKey) throw new Error("AWS KMS returned no public key");
  return out.PublicKey; // DER SubjectPublicKeyInfo
}

// Google Cloud KMS: EC_SIGN_P256_SHA256 key version. Returns DER; CRC32C guards the request and response.
const gcp = new KeyManagementServiceClient();
export async function signWithGoogle(versionName: string, data: Uint8Array): Promise<Uint8Array> {
  const digest = sha256(data);
  const [res] = await gcp.asymmetricSign({ name: versionName, digest: { sha256: digest }, digestCrc32c: { value: crc32c(digest) } });
  if (res.name !== versionName || !res.verifiedDigestCrc32c) throw new Error("request corrupted in transit");
  const signature = Buffer.from(res.signature as Uint8Array);
  if (crc32c(signature) !== Number(res.signatureCrc32c?.value)) throw new Error("response corrupted in transit");
  return signature;
}

// Azure Key Vault: EC P-256 key, ES256. Returns r || s (64 bytes), the JWS format.
export async function signWithAzure(keyId: string, data: Uint8Array): Promise<Uint8Array> {
  const client = new CryptographyClient(keyId, new DefaultAzureCredential());
  const { result } = await client.sign("ES256", sha256(data));
  return result;
}

/** CRC32C (Castagnoli), as Cloud KMS uses for integrity fields. */
function crc32c(bytes: Uint8Array): number {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    crc ^= byte;
    for (let k = 0; k < 8; k++) crc = crc & 1 ? (crc >>> 1) ^ 0x82f63b78 : crc >>> 1;
  }
  return (crc ^ 0xffffffff) >>> 0;
}

A função auxiliar de CRC32C retornou 0xe3069283 para a entrada "123456789", o valor de verificação publicado do CRC-32C. O guia de assinatura do Google Cloud recomenda as três verificações mostradas: verifiedDigestCrc32c indica que a requisição chegou íntegra, name, que a versão certa da chave assinou, e signatureCrc32c, que a resposta chegou íntegra.

A saída bruta do Azure é o formato das APIs CNG do Windows que ficam por baixo; em 2018, a equipe do SDK do Azure o descreveu como “formato ‘bruto’ (o mesmo usado pelas APIs bcrypt e ncrypt do Windows)” (azure-keyvault-java #58). Isso é conveniente para o JOSE e inconveniente para tudo o que fala X.509.

Verifique localmente, não com o serviço

Os três serviços oferecem uma chamada Verify, e os três permitem baixar a chave pública. Prefira a segunda opção: verificar com a chave pública não custa nada, não tem cota nem dependência de rede, e é o que suas partes confiáveis (relying parties) vão fazer de qualquer forma. Se seu próprio teste só passa pelo Verify do serviço, ele pode esconder uma incompatibilidade de formato que todos os outros verificadores enxergam. A AWS diz o mesmo sobre a chave de condição kms:SigningAlgorithm: ela “não tem efeito sobre operações realizadas fora do AWS KMS, como a verificação de assinaturas com a chave pública”.

As cotas são outro motivo. O AWS KMS permite 1.000 requisições por segundo, compartilhadas entre Sign e Verify em todas as chaves ECC de uma conta e região, e outro tanto para as chaves RSA e para as chaves ML-DSA (cotas de requisições, ajustáveis). Um serviço que assina cada token de sessão pelo KMS chega a esse teto antes de um que assina algumas releases por dia; assine chaves de dados de curta duração ou agrupe as assinaturas em lotes sempre que puder.

Permissões: uma chave, só assinatura

A unidade útil de permissão é “esta carga de trabalho pode assinar com esta chave”, nada mais:

  • AWS KMS: permita kms:Sign no ARN da chave, na política de chaves ou em uma política do IAM, e restrinja a permissão com chaves de condição: kms:SigningAlgorithm fixa o algoritmo, e kms:MessageType pode proibir DIGEST quando só mensagens pequenas devem ser assinadas (um digest permite a quem chama assinar quaisquer dados). A permissão kms:GetPublicKey pode ser concedida separadamente aos verificadores. O uso entre contas é possível com o ARN da chave.
  • Google Cloud KMS: conceda roles/cloudkms.signer (“Permite operações de assinatura”, permissão cloudkms.cryptoKeyVersions.useToSign) em uma única CryptoKey, e roles/cloudkms.publicKeyViewer a quem só busca a chave pública. roles/cloudkms.signerVerifier combina assinatura, verificação e acesso à chave pública.
  • Azure Key Vault: com o Azure RBAC (o padrão para cofres criados com a versão de API 2026-02-01 ou posterior), o papel “Key Vault Crypto User” permite operações criptográficas com chaves, e o “Key Vault Crypto Officer” também as gerencia. Para permitir só a assinatura, crie um papel personalizado com a ação de dados Microsoft.KeyVault/vaults/keys/sign/action; o papel interno Crypto User também criptografa, descriptografa e encapsula chaves. A Microsoft recomenda um cofre por aplicação e por ambiente.

Convém que uma chave que assina releases, uma que assina tokens e uma que assina webhooks sejam três chaves distintas, cada uma com suas próprias entidades principais. Assim, uma rotação afeta um consumidor de cada vez.

Os logs de auditoria nem sempre estão ativados

Em um serviço de nuvem, só dá para prestar contas do uso de uma chave de assinatura se esse uso ficar registrado em log:

  • O AWS CloudTrail registra por padrão as chamadas ao KMS, inclusive Sign, como eventos de gerenciamento; a entrada do log traz a chave, quem chamou, o algoritmo e o tipo de mensagem, mas não a mensagem nem a assinatura. Uma trilha pode excluir por completo os eventos do KMS (ExcludeManagementEventSources); se faltarem entradas do KMS, procure um evento PutEventSelectors.
  • O Google Cloud classifica AsymmetricSign como uma operação de acesso a dados (Data Access, DATA_READ), e “os logs de auditoria de acesso a dados ficam desativados por padrão em todos os serviços, exceto em alguns serviços do BigQuery” (Cloud Logging). Se você não os ativar para o Cloud KMS, não haverá nenhum registro das assinaturas.
  • O Azure Key Vault grava registros AuditEvent (nome da operação KeySign) somente depois que você define configurações de diagnóstico que os enviem para uma conta de armazenamento ou um workspace do Log Analytics.

Para a assinatura de código, isso vai além de uma boa prática: desde junho de 2023, os Code Signing Baseline Requirements do CA/Browser Forum (§6.2.7.4.1) só permitem um serviço de chaves em nuvem para chaves de assinatura de código publicamente confiáveis se a conta (subscription) estiver “configurada para registrar todos os acessos, operações e alterações de configuração nos recursos que protegem a chave privada”. O artigo assinatura de código com Authenticode trata desse caso de uso.

Erros comuns

  • DER em um JWT, ou r ‖ s entregue a um verificador que espera DER. Confira o tamanho: 64 bytes para ES256, de 68 a 72 bytes começando com 0x30 para DER. O conversor de DER para bruto converte uma assinatura nos dois sentidos.
  • Conversão sem o preenchimento à esquerda, que funciona em 99% das assinaturas.
  • Hash duplo: um digest enviado como mensagem.
  • Ed25519ph onde se esperava Ed25519, ou o contrário.
  • Assinaturas high-S em blockchains. As assinaturas ECDSA de chaves secp256k1 do AWS KMS precisam de normalização low-S e de um ID de recuperação antes que o Ethereum as aceite; o artigo recuperação da chave pública ECDSA mostra como.
  • Verificar pelo serviço só nos testes. Baixe a chave pública e verifique do jeito que uma parte confiável vai verificar.
  • Papéis amplos e logs ausentes. Um papel que pode assinar com qualquer chave de um projeto, sem log de acesso a dados, não deixa rastro do que foi assinado.
  • Supor que a chave pode mudar de lugar. As chaves geradas nesses serviços não são exportáveis; uma migração para outro provedor significa uma chave nova e uma rotação publicada.

Fontes