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 KMS | Google Cloud KMS | Azure Key Vault | |
|---|---|---|---|
| Llamada de firma | Sign | AsymmetricSign | sign (REST keys/{name}/{version}/sign) |
| Entrada | mensaje de hasta 4096 bytes o resumen (MessageType RAW, DIGEST, EXTERNAL_MU) | digest o data, nunca ambos | siempre un resumen |
| Salida ECDSA | DER (RFC 3279) | DER | r ‖ s (formato del RFC 7518) |
| Claves EC | P-256, P-384, P-521, secp256k1 | P-256, P-384, secp256k1 (solo HSM) | P-256, P-256K (secp256k1), P-384, P-521 |
| Ed25519 | sí, puro y con pre-hash | sí, puro (solo claves de software) | no |
| RSA | 2048, 3072, 4096; PSS y PKCS #1 v1.5 | 2048, 3072, 4096; PSS, PKCS #1 v1.5, PKCS #1 en bruto | 2048, 3072, 4096; PSS y PKCS #1 v1.5 |
| Algoritmos poscuánticos | ML-DSA-44/65/87 | ML-DSA-44/65/87, SLH-DSA-SHA2-128s (claves de software) | ninguno |
| Protección de la clave | HSM FIPS 140-3 de nivel 3 | software (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 comoRAW«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 ensha384, según el algoritmo de la clave). El campodataes 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»;
ES256espera 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:
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
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:
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:
/** 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)}`;
}
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
ro unsde 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.
// 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:Signsobre 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:SigningAlgorithmfija el algoritmo ykms:MessageTypepuede prohibirDIGESTcuando solo se deban firmar mensajes pequeños (un resumen permite a quien llama firmar cualquier dato).kms:GetPublicKeyse 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», permisocloudkms.cryptoKeyVersions.useToSign) sobre esa única CryptoKey, yroles/cloudkms.publicKeyViewera quien solo obtiene la clave pública.roles/cloudkms.signerVerifiercombina 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 eventoPutEventSelectors. - Google Cloud clasifica
AsymmetricSigncomo 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ónKeySign) 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 ‖ sentregado a un verificador que espera DER. Comprueba la longitud: 64 bytes para ES256; de 68 a 72 bytes, empezando por0x30, 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
- AWS KMS: referencia de la API
Sign, referencia de especificaciones de clave, claves de condición, cuotas de solicitudes, registro con CloudTrail - Google Cloud KMS: algoritmos, creación y validación de firmas, roles, registros de auditoría
- Azure Key Vault: tipos de clave y algoritmos, API REST
sign, Azure RBAC, registros - RFC 7518 §3.4 (ECDSA en JWS), RFC 3279 §2.2.3 (ECDSA en DER), RFC 8032 §5.1 (Ed25519 y Ed25519ph)
- Requisitos básicos de firma de código del CA/Browser Forum 3.11.0, §6.2.7.4