Saltar al contenido
ecdsa.com
← Todos los artículos

Firma con KMS en la nube: AWS, Google Cloud y Azure

La clave privada nunca sale del servicio, así que cada peculiaridad de la API de firma se convierte en problema tuyo: qué envías, de qué se calcula el hash y qué codificación recibes de vuelta. Revisamos la documentación de cada proveedor el 20 de octubre de 2026 y reprodujimos en local cada trampa de formato.

Respuesta breve. Los tres servicios guardan la clave privada en sus HSM o módulos de software y firman lo que les envías. Las diferencias que rompen las integraciones son pequeñas y concretas. AWS KMS y Google Cloud KMS devuelven las firmas ECDSA codificadas en DER; un JWT necesita la forma r ‖ s de 64 bytes, así que convierte la firma antes de codificarla en base64url. Azure Key Vault ya devuelve r ‖ s. Envía un resumen para cualquier cosa de más de unos pocos kilobytes e indica al servicio que se trata de un resumen (MessageType: DIGEST en AWS, el campo digest en Google Cloud; Azure siempre espera uno), o se calculará el hash dos veces. Concede a quienes llaman al servicio un permiso solo de firma sobre una única clave, activa los registros de auditoría que vienen desactivados por defecto (los registros de Data Access de Google Cloud, la configuración de diagnóstico de Azure) y verifica las firmas sin conexión con la clave pública en lugar de llamar al servicio.

Las llamadas a la nube de esta página no se pudieron ejecutar en un contenedor. Su código pasó la comprobación de tipos de tsc --strict con las versiones fijadas de los SDK, y cada problema de formato se reprodujo con una clave local que se comporta como el servicio: el mismo hash y la misma codificación de salida.

Los tres servicios, comparados

AWS KMSGoogle Cloud KMSAzure Key Vault
Llamada de firmaSignAsymmetricSignsign (REST keys/{name}/{version}/sign)
Entradamensaje de hasta 4096 bytes o resumen (MessageType RAW, DIGEST, EXTERNAL_MU)digest o data, nunca ambossiempre un resumen
Salida ECDSADER (RFC 3279)DERr ‖ s (formato del RFC 7518)
Claves ECP-256, P-384, P-521, secp256k1P-256, P-384, secp256k1 (solo HSM)P-256, P-256K (secp256k1), P-384, P-521
Ed25519sí, puro y con pre-hashsí, puro (solo claves de software)no
RSA2048, 3072, 4096; PSS y PKCS #1 v1.52048, 3072, 4096; PSS, PKCS #1 v1.5, PKCS #1 en bruto2048, 3072, 4096; PSS y PKCS #1 v1.5
Algoritmos poscuánticosML-DSA-44/65/87ML-DSA-44/65/87, SLH-DSA-SHA2-128s (claves de software)ninguno
Protección de la claveHSM FIPS 140-3 de nivel 3software (primitivas FIPS 140-3 L1) o Cloud HSM (FIPS 140-2 L3)software (FIPS 140-2 L1) o HSM (FIPS 140-3 L3 en la plataforma HSM 2, Premium)

Fuentes: AWS, referencia de especificaciones de clave y Sign; Google Cloud, algoritmos y niveles de protección; Azure, tipos de clave y algoritmos. Dos detalles de esas páginas importan más adelante: para secp256k1, Google Cloud KMS «genera firmas solo en la forma “normalizada” (también conocida como “forma lower-S”)», y AWS KMS no documenta ninguna garantía de ese tipo.

Resumen o mensaje

Las firmas ECDSA, RSA y ML-DSA empiezan calculando el hash de la entrada. Un KMS puede hacerlo por ti, pero AWS acepta como máximo 4096 bytes de mensaje y Azure solo acepta resúmenes, así que en la práctica calculas el hash en local y envías el valor SHA-256 de 32 bytes. El servicio tiene que saltarse entonces su propio paso de hash, y tú tienes que indicárselo:

  • AWS KMS: MessageType: "DIGEST". La referencia de la API advierte de que enviar un resumen como RAW «puede provocar fallos de verificación al verificar con un sistema que supone un único hash».
  • Google Cloud KMS: pon el valor en digest.sha256 (o en sha384, según el algoritmo de la clave). El campo data es obligatorio para los algoritmos que reciben la entrada en bruto, como Ed25519.
  • Azure Key Vault: la operación se describe como «Crea una firma a partir de un resumen»; ES256 espera un valor SHA-256 de 32 bytes.

Así se ve el error del doble hash, reproducido con OpenSSL 3.6.5 y una clave P-256 local en lugar de la clave del KMS:

files.sh (fragmento): firma de un archivo de 10 MB mediante su resumen y, después, el error del doble hash
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
Salida (debian:forky-20261005-slim, OpenSSL 3.6.5)
Verified OK
Verification failure
Verified OK

La segunda firma es válida, solo que no sobre el archivo: firma como mensaje el archivo que contiene el resumen. Cualquier verificador estándar calcula el hash del archivo una sola vez, así que la verificación falla. La corrección es un único parámetro, y por eso este fallo sobrevive a la revisión de código.

Ed25519 tiene su propia variante del problema. AWS KMS ofrece dos algoritmos para las claves ECC_NIST_EDWARDS25519: ED25519_SHA_512 (Ed25519 puro; requiere MessageType: RAW, así que admite como máximo 4096 bytes) y ED25519_PH_SHA_512 (Ed25519ph del RFC 8032; requiere DIGEST, y AWS aplica de todos modos su propio pre-hash SHA-512: «se calcula el hash de la entrada dos veces: una vez lo calculas tú y otra, KMS»). Los dos producen firmas distintas que no se verifican con el algoritmo del otro:

openssl pkeyutl -verify -rawin, con y sin -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

La mayoría de los verificadores de Ed25519 (EdDSA de JOSE, SSH, minisign) solo implementan Ed25519 puro. Usa ED25519_SHA_512 con entradas pequeñas, como la entrada de firma de un JWT, y, para los archivos, firma un manifiesto que contenga el hash del archivo.

Firma de JWT: DER frente a r ‖ s

Un JWS con alg: ES256 lleva una firma de exactamente 64 bytes, con r y s rellenados cada uno por la izquierda hasta 32 bytes (RFC 7518 §3.4). AWS y Google Cloud devuelven en su lugar la SEQUENCE { INTEGER r, INTEGER s } de DER. El laboratorio firma un JWT con una clave P-256 local que devuelve DER, exactamente igual que esos servicios, y lo verifica con jose y jsonwebtoken antes y después de la conversión:

jwt.mjs (fragmento): conversión de DER a 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)}`;
}
Salida (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)

Aquí kmsSignDer es sign("sha256", data, privateKey) de node:crypto, que devuelve DER igual que los servicios. Conviene recordar tres cosas:

  • jose rechaza el DER sin dar ninguna pista sobre el formato; jsonwebtoken sí lo menciona: ES256 signatures must be 64 bytes. Los verificadores de Java dicen Invalid encoding for signature cuando reciben el formato contrario.
  • La longitud del DER varía entre 68 y 72 bytes. El código que corta en una posición fija, o que da por hecho que son 70 bytes, funciona en las pruebas y falla en producción.
  • Aproximadamente una firma de cada 128 tiene un r o un s de menos de 32 bytes (172 de 20.000 en esta ejecución). Un conversor que olvida el relleno por la izquierda produce una firma de 63 bytes que algunos verificadores rechazan y otros interpretan mal: el tipo de fallo intermitente que se manifiesta como «falla uno de cada cien inicios de sesión». Formatos de firma ECDSA trata las codificaciones en detalle.

En Azure Key Vault, los bytes de SignResult.result ya son r ‖ s y van directamente al JWS. Lo que hace falta entonces es la conversión inversa para los verificadores que esperan DER, como SHA256withECDSA de Java u OpenSSL.

El kid debería identificar la versión de la clave, no solo la clave, para que los verificadores puedan elegir la clave pública correcta durante una rotación. Publica la clave pública como JWK: GetPublicKey de AWS y GetPublicKey de Google Cloud devuelven SPKI (DER o PEM), y Azure devuelve una JWK. La rotación sin romper los verificadores es el tema de Rotación de claves JWKS.

Cómo llamar a cada servicio

Las tres funciones siguientes firman el resumen SHA-256 de unos datos y devuelven los bytes que entrega cada servicio. Pasaron la comprobación de tipos de tsc --strict (TypeScript 5.9.3) con @aws-sdk/client-kms 3.1153.0, @google-cloud/kms 6.3.0, @azure/keyvault-keys 4.10.2 y @azure/identity 4.14.0; no se ejecutaron contra los servicios, así que no se muestra ninguna salida.

kms.ts (con los tipos comprobados, sin ejecutar contra los servicios en la nube)
// 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;
}

La función auxiliar CRC32C devolvió 0xe3069283 para la entrada "123456789", el valor de comprobación publicado de CRC-32C. La guía de firma de Google Cloud recomienda las tres comprobaciones que se muestran: verifiedDigestCrc32c indica que la solicitud llegó intacta; name, que firmó la versión de clave correcta, y signatureCrc32c, que la respuesta llegó intacta.

La salida en bruto de Azure es el formato de las API de Windows CNG que hay por debajo; el equipo del SDK de Azure la describió en 2018 como formato «“raw” (el mismo que usan las API bcrypt y ncrypt de Windows)» (azure-keyvault-java #58). Eso resulta práctico para JOSE e incómodo para todo lo que habla X.509.

Verifica en local, no con el servicio

Los tres servicios ofrecen una llamada Verify, y los tres permiten descargar la clave pública. Prefiere lo segundo: verificar con la clave pública no cuesta nada, no tiene cuota ni dependencia de la red, y es lo que harán de todos modos tus relying parties (las partes que confían en tus firmas). Si tu propia prueba solo pasa por el Verify del servicio, puede ocultar una discrepancia de formato que cualquier otro verificador sí ve. AWS dice lo mismo de su clave de condición kms:SigningAlgorithm: «no tiene ningún efecto en las operaciones realizadas fuera de AWS KMS, como la verificación de firmas con la clave pública».

Las cuotas son otra razón. AWS KMS permite 1000 solicitudes por segundo, compartidas por Sign y Verify entre todas las claves ECC de una cuenta y región, y otras tantas para las claves RSA y para las ML-DSA (cuotas de solicitudes, ajustables). Un servicio que firma cada token de sesión mediante KMS alcanza ese techo antes que uno que firma unas pocas versiones publicadas al día; firma claves de datos de corta duración o agrupa las operaciones siempre que puedas.

Permisos: una clave, solo firma

La unidad útil de permiso es «esta carga de trabajo puede firmar con esta clave», y nada más:

  • AWS KMS: autoriza kms:Sign sobre el ARN de la clave, en la política de clave o en una política de IAM, y restríngelo con claves de condición: kms:SigningAlgorithm fija el algoritmo y kms:MessageType puede prohibir DIGEST cuando solo se deban firmar mensajes pequeños (un resumen permite a quien llama firmar cualquier dato). kms:GetPublicKey se puede conceder por separado a los verificadores. El uso desde otras cuentas es posible con el ARN de la clave.
  • Google Cloud KMS: concede roles/cloudkms.signer («Habilita las operaciones de firma», permiso cloudkms.cryptoKeyVersions.useToSign) sobre esa única CryptoKey, y roles/cloudkms.publicKeyViewer a quien solo obtiene la clave pública. roles/cloudkms.signerVerifier combina firma, verificación y acceso a la clave pública.
  • Azure Key Vault: con Azure RBAC (el modelo predeterminado de los almacenes creados con la versión de API 2026-02-01 y posteriores), «Key Vault Crypto User» permite realizar operaciones criptográficas con las claves, y «Key Vault Crypto Officer», además, administrarlas. Para permitir solo la firma, crea un rol personalizado con la acción de datos Microsoft.KeyVault/vaults/keys/sign/action; el rol integrado Crypto User también cifra, descifra y encapsula. Microsoft recomienda un almacén por aplicación y entorno.

Una clave que firma versiones publicadas, una que firma tokens y otra que firma webhooks deberían ser tres claves distintas, cada una con sus propias entidades principales. Así, la rotación afecta a un solo consumidor cada vez.

Los registros de auditoría no siempre están activados

Una clave de firma en un servicio en la nube solo es tan auditable como el registro de su uso:

  • AWS CloudTrail registra por defecto las llamadas a KMS, incluida Sign, como eventos de administración; la entrada del registro contiene la clave, quién llama, el algoritmo y el tipo de mensaje, pero no el mensaje ni la firma. Un registro de seguimiento (trail) puede excluir por completo los eventos de KMS (ExcludeManagementEventSources); si faltan entradas de KMS, busca un evento PutEventSelectors.
  • Google Cloud clasifica AsymmetricSign como una operación de Data Access (DATA_READ), y «los registros de auditoría de Data Access están desactivados por defecto para todos los servicios, salvo algunos servicios de BigQuery» (Cloud Logging). Si no los activas para Cloud KMS, no tienes ningún registro de las firmas.
  • Azure Key Vault escribe registros AuditEvent (nombre de operación KeySign) solo después de que crees una configuración de diagnóstico que los envíe a una cuenta de almacenamiento o a un área de trabajo de Log Analytics.

No es solo una buena práctica para la firma de código: desde junio de 2023, los requisitos básicos de firma de código (Code Signing Baseline Requirements) del CA/Browser Forum (§6.2.7.4.1) solo permiten usar un servicio de claves en la nube para las claves de firma de código de confianza pública si la suscripción está «configurada para registrar todos los accesos, operaciones y cambios de configuración en los recursos que protegen la clave privada». Firma de código Authenticode trata ese caso de uso.

Errores habituales

  • DER en un JWT, o r ‖ s entregado a un verificador que espera DER. Comprueba la longitud: 64 bytes para ES256; de 68 a 72 bytes, empezando por 0x30, para DER. El conversor DER ⇄ en bruto convierte una firma suelta en ambos sentidos.
  • Conversión sin relleno que funciona con el 99 % de las firmas.
  • Doble hash: un resumen enviado como mensaje.
  • Ed25519ph donde se esperaba Ed25519, o al revés.
  • Firmas high-S en cadenas de bloques. Las firmas ECDSA de las claves secp256k1 de AWS KMS necesitan normalización low-S y un ID de recuperación antes de que Ethereum las acepte; Recuperación de la clave pública ECDSA muestra cómo.
  • Verificar a través del servicio solo en las pruebas. Descarga la clave pública y verifica como lo hará una relying party.
  • Roles amplios y registros ausentes. Un rol que puede firmar con cualquier clave de un proyecto, sin un registro de Data Access, no deja rastro de lo que se firmó.
  • Suponer que una clave se puede trasladar. Las claves generadas en estos servicios no son exportables; una migración a otro proveedor significa una clave nueva y una rotación publicada.

Fuentes