En bref. Les trois services conservent la clé privée dans leurs HSM ou leurs modules logiciels et signent ce que vous leur envoyez. Les différences qui cassent les intégrations sont minimes et bien identifiées. AWS KMS et Google Cloud KMS renvoient des signatures ECDSA encodées en DER ; un JWT exige la forme r ‖ s de 64 octets, convertissez donc la signature avant de l’encoder en base64url. Azure Key Vault renvoie déjà r ‖ s. Envoyez un condensat pour toute donnée de plus de quelques kilo-octets, et indiquez au service qu’il s’agit d’un condensat (MessageType: DIGEST chez AWS, le champ digest chez Google Cloud ; Azure en attend toujours un), sinon il sera haché deux fois. Accordez aux appelants une autorisation de signature seule sur une clé unique, activez les journaux d’audit désactivés par défaut (journaux d’accès aux données de Google Cloud, paramètres de diagnostic d’Azure) et vérifiez les signatures hors ligne avec la clé publique plutôt qu’en appelant le service.
Les appels aux services cloud présentés sur cette page n’ont pas pu être exécutés dans un conteneur. Leur code a passé la vérification de types de tsc --strict avec les versions épinglées des SDK, et chaque problème de format a été reproduit avec une clé locale qui se comporte comme le service : même hachage, même encodage en sortie.
Les trois services côte à côte
| AWS KMS | Google Cloud KMS | Azure Key Vault | |
|---|---|---|---|
| Appel de signature | Sign | AsymmetricSign | sign (REST keys/{name}/{version}/sign) |
| Entrée | message de 4096 octets au plus, ou condensat (MessageType RAW, DIGEST, EXTERNAL_MU) | digest ou data, jamais les deux | toujours un condensat |
| Sortie ECDSA | DER (RFC 3279) | DER | r ‖ s (format de la RFC 7518) |
| Clés EC | P-256, P-384, P-521, secp256k1 | P-256, P-384, secp256k1 (HSM uniquement) | P-256, P-256K (secp256k1), P-384, P-521 |
| Ed25519 | oui, pur et avec pré-hachage | oui, pur (clés logicielles uniquement) | non |
| RSA | 2048, 3072, 4096 ; PSS et PKCS #1 v1.5 | 2048, 3072, 4096 ; PSS, PKCS #1 v1.5, PKCS #1 brut | 2048, 3072, 4096 ; PSS et PKCS #1 v1.5 |
| Post-quantique | ML-DSA-44/65/87 | ML-DSA-44/65/87, SLH-DSA-SHA2-128s (clés logicielles) | aucun |
| Protection des clés | HSM FIPS 140-3 niveau 3 | logiciel (primitives FIPS 140-3 niveau 1) ou Cloud HSM (FIPS 140-2 niveau 3) | logiciel (FIPS 140-2 niveau 1) ou HSM (FIPS 140-3 niveau 3 sur la plateforme HSM 2, Premium) |
Sources : pour AWS, la référence des spécifications de clés et Sign ; pour Google Cloud, les algorithmes et les niveaux de protection ; pour Azure, les types de clés et les algorithmes. Deux détails de ces pages comptent pour la suite : pour secp256k1, Google Cloud KMS « génère des signatures uniquement sous la forme “normalisée” (également appelée “forme lower-S”) », alors qu’AWS KMS ne documente aucune garantie de ce genre.
Condensat ou message
Les signatures ECDSA, RSA et ML-DSA commencent par hacher l’entrée. Un KMS peut le faire à votre place, mais AWS accepte au plus 4096 octets de message et Azure n’accepte que des condensats : en pratique, vous hachez donc localement et envoyez la valeur SHA-256 de 32 octets. Le service doit alors sauter sa propre étape de hachage, et c’est à vous de le lui indiquer :
- AWS KMS :
MessageType: "DIGEST". La référence de l’API avertit qu’envoyer un condensat enRAW« peut provoquer des échecs de vérification lorsque la vérification se fait avec un système qui suppose un hachage unique ». - Google Cloud KMS : placez la valeur dans
digest.sha256(ousha384, selon l’algorithme de la clé). Le champdataest obligatoire pour les algorithmes à entrée brute comme Ed25519. - Azure Key Vault : l’opération est décrite comme « Crée une signature à partir d’un condensat » ;
ES256attend une valeur SHA-256 de 32 octets.
Voici à quoi ressemble l’erreur du double hachage, reproduite avec OpenSSL 3.6.5 et une clé P-256 locale à la place de la clé 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 seconde signature est valide, mais pas pour le fichier : elle signe le fichier de condensat comme s’il s’agissait du message. Tout vérificateur standard hache le fichier une seule fois, et la vérification échoue donc. La correction tient en un seul paramètre, et c’est pourquoi ce bogue survit à la revue de code.
Ed25519 a sa propre variante du problème. AWS KMS propose deux algorithmes pour les clés ECC_NIST_EDWARDS25519 : ED25519_SHA_512 (Ed25519 pur, exige MessageType: RAW, donc 4096 octets au plus) et ED25519_PH_SHA_512 (Ed25519ph de la RFC 8032, exige DIGEST, et AWS applique malgré tout son propre pré-hachage SHA-512 : « l’entrée est hachée deux fois, une fois par vous et une fois par KMS »). Les deux produisent des signatures différentes, et aucune ne se vérifie selon l’autre algorithme :
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 plupart des vérificateurs Ed25519 (EdDSA de JOSE, SSH, minisign) n’implémentent qu’Ed25519 pur. Utilisez ED25519_SHA_512 pour les petites entrées, comme l’entrée de signature d’un JWT, et, pour les fichiers, signez un manifeste qui contient le condensat du fichier.
Signer des JWT : DER ou r ‖ s
Un JWS avec alg: ES256 porte une signature d’exactement 64 octets, r et s étant chacun complétés à 32 octets par un remplissage à gauche (RFC 7518 §3.4). AWS et Google Cloud renvoient à la place la structure DER SEQUENCE { INTEGER r, INTEGER s }. Le laboratoire signe un JWT avec une clé P-256 locale qui renvoie du DER, exactement comme ces services, puis le vérifie avec jose et jsonwebtoken avant et après conversion :
/** 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)
Ici, kmsSignDer est sign("sha256", data, privateKey) de node:crypto, qui renvoie du DER comme les services. Trois points sont à retenir :
- jose rejette le DER sans rien indiquer sur le format ; jsonwebtoken, lui, nomme le problème : ES256 signatures must be 64 bytes. Les vérificateurs Java affichent Invalid encoding for signature quand on leur fournit le format inverse.
- La longueur DER varie de 68 à 72 octets. Un code qui découpe à un décalage fixe, ou qui suppose 70 octets, fonctionne en test et échoue en production.
- Environ une signature sur 128 a un
rou unsde moins de 32 octets (172 sur 20 000 dans cette exécution). Un convertisseur qui oublie le remplissage à gauche produit une signature de 63 octets que certains vérificateurs rejettent et que d’autres interprètent mal : c’est le genre de bogue intermittent qui se manifeste par « une connexion sur cent échoue ». L’article Formats de signature ECDSA décrit les encodages en détail.
Avec Azure Key Vault, les octets de SignResult.result sont déjà au format r ‖ s et vont directement dans le JWS. C’est alors la conversion inverse qui est nécessaire pour les vérificateurs qui attendent du DER, comme SHA256withECDSA de Java ou OpenSSL.
Le kid devrait désigner la version de la clé, et pas seulement la clé, afin que les vérificateurs puissent choisir la bonne clé publique pendant une rotation. Publiez la clé publique sous forme de JWK : GetPublicKey d’AWS et GetPublicKey de Google Cloud renvoient du SPKI (DER ou PEM), Azure renvoie une JWK. La rotation sans casser les vérificateurs fait l’objet de l’article Rotation des clés JWKS.
Appeler chaque service
Les trois fonctions ci-dessous signent le condensat SHA-256 de données quelconques et renvoient les octets que chaque service retourne. Leur typage a été vérifié par tsc --strict (TypeScript 5.9.3) avec @aws-sdk/client-kms 3.1153.0, @google-cloud/kms 6.3.0, @azure/keyvault-keys 4.10.2 et @azure/identity 4.14.0 ; elles n’ont pas été exécutées sur les services, d’où l’absence de sortie.
// 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 fonction utilitaire CRC32C a renvoyé 0xe3069283 pour l’entrée "123456789", soit la valeur de contrôle publiée de CRC-32C. Le guide de signature de Google Cloud recommande les trois vérifications présentées : verifiedDigestCrc32c indique que la requête est arrivée intacte, name que c’est la bonne version de clé qui a signé, et signatureCrc32c que la réponse est arrivée intacte.
La sortie brute d’Azure est le format des API Windows CNG sous-jacentes ; l’équipe du SDK Azure l’a décrite en 2018 comme le « format “brut” (le même que celui des API Windows bcrypt et ncrypt) » (azure-keyvault-java #58). C’est pratique pour JOSE, et peu pratique pour tout ce qui parle X.509.
Vérifier localement, pas avec le service
Les trois services proposent un appel Verify, et tous trois permettent de télécharger la clé publique. Préférez la seconde option : vérifier avec la clé publique ne coûte rien, n’est soumis à aucun quota ni à aucune dépendance réseau, et c’est de toute façon ce que feront vos parties de confiance (relying parties). Si votre propre test ne passe que par le Verify du service, il peut masquer une incompatibilité de format que tous les autres vérificateurs verront. AWS dit la même chose de sa clé de condition kms:SigningAlgorithm : elle « n’a aucun effet sur les opérations effectuées en dehors d’AWS KMS, comme la vérification de signatures avec la clé publique ».
Les quotas sont une autre raison. AWS KMS autorise 1000 requêtes par seconde, partagées entre Sign et Verify pour toutes les clés ECC d’un compte et d’une Région, et autant pour les clés RSA et pour les clés ML-DSA (quotas de requêtes, ajustables). Un service qui signe chaque jeton de session via KMS atteint ce plafond plus tôt qu’un service qui signe quelques versions publiées par jour ; signez plutôt des clés de données de courte durée, ou regroupez les signatures par lots, quand c’est possible.
Autorisations : une clé, la signature seule
La bonne granularité d’autorisation est « cette charge de travail peut signer avec cette clé », rien de plus :
- AWS KMS : autorisez
kms:Signsur l’ARN de la clé, dans la politique de clé ou dans une politique IAM, et restreignez-le avec des clés de condition :kms:SigningAlgorithmfixe l’algorithme etkms:MessageTypepeut interdireDIGESTlorsque seuls de petits messages doivent être signés (un condensat permet à l’appelant de signer n’importe quelles données).kms:GetPublicKeypeut être accordé séparément aux vérificateurs. L’utilisation entre comptes est possible avec l’ARN de la clé. - Google Cloud KMS : accordez
roles/cloudkms.signer(« Permet d’effectuer des opérations de signature », autorisationcloudkms.cryptoKeyVersions.useToSign) sur cette seule CryptoKey, etroles/cloudkms.publicKeyViewerà qui ne fait que récupérer la clé publique.roles/cloudkms.signerVerifiercombine signature, vérification et accès à la clé publique. - Azure Key Vault : avec Azure RBAC (le modèle par défaut des coffres créés avec la version d’API 2026-02-01 ou ultérieure), « Key Vault Crypto User » autorise les opérations cryptographiques avec les clés, et « Key Vault Crypto Officer » permet en plus de les gérer. Pour la signature seule, créez un rôle personnalisé avec l’action de données
Microsoft.KeyVault/vaults/keys/sign/action; le rôle intégré Crypto User permet aussi de chiffrer, de déchiffrer et d’encapsuler des clés. Microsoft recommande un coffre par application et par environnement.
La clé qui signe les versions publiées, celle qui signe les jetons et celle qui signe les webhooks devraient être trois clés distinctes, chacune avec ses propres principaux. Une rotation n’affecte alors qu’un seul consommateur à la fois.
Les journaux d’audit ne sont pas toujours activés
Une clé de signature hébergée dans un service cloud ne vaut, en matière de traçabilité, que ce que vaut le journal de son utilisation :
- AWS CloudTrail enregistre par défaut les appels KMS,
Signcompris, comme événements de gestion ; l’entrée du journal contient la clé, l’appelant, l’algorithme et le type de message, mais ni le message ni la signature. Un journal de suivi (trail) peut exclure entièrement les événements KMS (ExcludeManagementEventSources) ; cherchez un événementPutEventSelectorss’il manque des entrées KMS. - Google Cloud classe
AsymmetricSigncomme une opération d’accès aux données (DATA_READ), et « les journaux d’audit d’accès aux données sont désactivés par défaut pour tous les services, à l’exception de certains services BigQuery » (Cloud Logging). Si vous ne les activez pas pour Cloud KMS, vous n’avez aucune trace des signatures. - Azure Key Vault n’écrit des enregistrements
AuditEvent(nom d’opérationKeySign) qu’après que vous avez configuré des paramètres de diagnostic pour les envoyer vers un compte de stockage ou un espace de travail Log Analytics.
Pour la signature de code, ce n’est pas seulement une bonne pratique : depuis juin 2023, les Code Signing Baseline Requirements du CA/Browser Forum (§6.2.7.4.1) n’autorisent un service de clés dans le cloud pour les clés de signature de code de confiance publique que si l’abonnement est « configuré pour journaliser tous les accès, toutes les opérations et toutes les modifications de configuration sur les ressources qui protègent la clé privée ». L’article Signature de code Authenticode traite ce cas d’usage.
Erreurs fréquentes
- Du DER dans un JWT, ou un
r ‖ sfourni à un vérificateur DER. Vérifiez la longueur : 64 octets pour ES256, de 68 à 72 octets commençant par0x30pour DER. Le convertisseur DER vers brut convertit une signature isolée dans les deux sens. - Une conversion sans remplissage, qui fonctionne pour 99 % des signatures.
- Le double hachage : un condensat envoyé comme message.
- Ed25519ph là où Ed25519 était attendu, ou l’inverse.
- Des signatures high-S sur les blockchains. Les signatures ECDSA des clés secp256k1 d’AWS KMS ont besoin d’une normalisation low-S et d’un identifiant de récupération pour qu’Ethereum les accepte ; l’article Récupération de clé publique ECDSA montre comment faire.
- Ne vérifier que via le service dans les tests. Téléchargez la clé publique et vérifiez comme le fera une partie de confiance.
- Des rôles trop larges et des journaux absents. Un rôle qui peut signer avec toutes les clés d’un projet, sans journal d’accès aux données, ne laisse aucune trace de ce qui a été signé.
- Croire qu’une clé peut être déplacée. Les clés générées dans ces services ne sont pas exportables ; une migration vers un autre fournisseur implique une nouvelle clé et une rotation annoncée publiquement.
Sources
- AWS KMS : référence de l’API
Sign, référence des spécifications de clés, clés de condition, quotas de requêtes, journalisation avec CloudTrail - Google Cloud KMS : algorithmes, création et validation de signatures, rôles, journaux d’audit
- Azure Key Vault : types de clés et algorithmes, API REST
sign, Azure RBAC, journalisation - RFC 7518 §3.4 (ECDSA dans JWS), RFC 3279 §2.2.3 (ECDSA en DER), RFC 8032 §5.1 (Ed25519 et Ed25519ph)
- Code Signing Baseline Requirements du CA/Browser Forum 3.11.0, §6.2.7.4