Zum Inhalt springen
ecdsa.com
← Alle Artikel

Signieren mit Cloud-KMS: AWS, Google Cloud und Azure

Der private Schlüssel verlässt den Dienst nie, deshalb wird jede Eigenheit der Signatur-API zu deinem Problem: was du sendest, was als Hash gilt und welche Kodierung zurückkommt. Wir haben die Dokumentation jedes Anbieters am 20. Oktober 2026 geprüft und jede Formatfalle lokal nachgestellt.

Kurz gesagt. Alle drei Dienste halten den privaten Schlüssel in ihren HSMs oder Softwaremodulen und signieren, was du ihnen sendest. Die Unterschiede, an denen Integrationen scheitern, sind klein und konkret. AWS KMS und Google Cloud KMS geben ECDSA-Signaturen DER-kodiert zurück; ein JWT braucht die 64-Byte-Form r ‖ s, wandle also um, bevor du das Ergebnis base64url-kodierst. Azure Key Vault liefert bereits r ‖ s. Sende für alles, was größer als ein paar Kilobyte ist, einen Digest und teile dem Dienst mit, dass es ein Digest ist (MessageType: DIGEST bei AWS, das Feld digest bei Google Cloud; Azure erwartet immer einen), sonst wird zweimal gehasht. Gib Aufrufern nur das Recht, mit einem einzigen Schlüssel zu signieren, schalte die Audit-Logs ein, die standardmäßig aus sind (bei Google Cloud die Data-Access-Logs, bei Azure die Diagnoseeinstellungen), und prüfe Signaturen offline mit dem öffentlichen Schlüssel, statt den Dienst aufzurufen.

Die Cloud-Aufrufe auf dieser Seite ließen sich nicht in einem Container ausführen. Der Code dafür wurde mit tsc --strict gegen die fest angegebenen SDK-Versionen typgeprüft, und jedes Formatproblem wurde mit einem lokalen Schlüssel nachgestellt, der sich wie der Dienst verhält: gleicher Hash, gleiche Ausgabekodierung.

Die drei Dienste im Vergleich

AWS KMSGoogle Cloud KMSAzure Key Vault
SignaturaufrufSignAsymmetricSignsign (REST keys/{name}/{version}/sign)
EingabeNachricht bis 4.096 Byte oder Digest (MessageType RAW, DIGEST, EXTERNAL_MU)digest oder data, nie beidesimmer ein Digest
ECDSA-AusgabeDER (RFC 3279)DERr ‖ s (Format nach RFC 7518)
EC-SchlüsselP-256, P-384, P-521, secp256k1P-256, P-384, secp256k1 (nur HSM)P-256, P-256K (secp256k1), P-384, P-521
Ed25519ja, rein und mit Pre-Hashja, rein (nur Software-Schlüssel)nein
RSA2048, 3072, 4096; PSS und PKCS #1 v1.52048, 3072, 4096; PSS, PKCS #1 v1.5, rohes PKCS #12048, 3072, 4096; PSS und PKCS #1 v1.5
Post-Quanten-VerfahrenML-DSA-44/65/87ML-DSA-44/65/87, SLH-DSA-SHA2-128s (Software-Schlüssel)keine
SchlüsselschutzHSMs nach FIPS 140-3 Level 3Software (Primitive nach FIPS 140-3 L1) oder Cloud HSM (FIPS 140-2 L3)Software (FIPS 140-2 L1) oder HSM (FIPS 140-3 L3 auf HSM-Plattform 2, Premium)

Quellen: AWS Referenz der Schlüsselspezifikationen und Sign; Google Cloud Algorithmen und Schutzstufen; Azure Schlüsseltypen und Algorithmen. Zwei Details von diesen Seiten sind später wichtig: Für secp256k1 erzeugt Google Cloud KMS Signaturen laut Dokumentation „nur in der ‚normalisierten‘ Form (auch als ‚Lower-S-Form‘ bekannt)“, und AWS KMS dokumentiert keine solche Garantie.

Digest oder Nachricht

Bei ECDSA-, RSA- und ML-DSA-Signaturen wird die Eingabe zuerst gehasht. Ein KMS kann das für dich übernehmen, aber AWS akzeptiert Nachrichten von höchstens 4.096 Byte, und Azure akzeptiert nur Digests. In der Praxis hashst du deshalb lokal und sendest den 32 Byte langen SHA-256-Wert. Der Dienst muss dann seinen eigenen Hash-Schritt überspringen, und das musst du ihm ausdrücklich sagen:

  • AWS KMS: MessageType: "DIGEST". Die API-Referenz warnt, dass ein als RAW gesendeter Digest „zu Fehlern bei der Prüfung führen kann, wenn mit einem System geprüft wird, das von einmaligem Hashing ausgeht“.
  • Google Cloud KMS: Übergib den Wert in digest.sha256 (oder in sha384, passend zum Algorithmus des Schlüssels). Für Algorithmen mit roher Eingabe wie Ed25519 ist das Feld data erforderlich.
  • Azure Key Vault: Die Operation ist als „Erstellt eine Signatur aus einem Digest“ beschrieben; ES256 erwartet einen 32 Byte langen SHA-256-Wert.

So sieht der Fehler mit dem doppelten Hash aus, nachgestellt mit OpenSSL 3.6.5 und einem lokalen P-256-Schlüssel anstelle des KMS-Schlüssels:

files.sh (Auszug): eine 10-MB-Datei über ihren Digest signieren, danach der Fehler mit doppeltem 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
Ausgabe (debian:forky-20261005-slim, OpenSSL 3.6.5)
Verified OK
Verification failure
Verified OK

Die zweite Signatur ist gültig, nur nicht für die Datei: Signiert wurde die Digest-Datei als Nachricht. Jeder standardkonforme Verifizierer hasht die Datei einmal, deshalb schlägt die Prüfung fehl. Die Korrektur ist ein einziger Parameter, und genau deshalb rutscht dieser Bug durchs Code-Review.

Ed25519 hat eine eigene Variante des Problems. AWS KMS bietet für Schlüssel vom Typ ECC_NIST_EDWARDS25519 zwei Algorithmen an: ED25519_SHA_512 (reines Ed25519, verlangt MessageType: RAW, also höchstens 4.096 Byte) und ED25519_PH_SHA_512 (Ed25519ph aus RFC 8032, verlangt DIGEST, und AWS wendet trotzdem seinen eigenen SHA-512-Pre-Hash an: „Die Eingabe wird zweimal gehasht: einmal von dir und einmal von KMS“). Die beiden erzeugen unterschiedliche Signaturen, und keine davon besteht die Prüfung nach dem jeweils anderen Verfahren:

openssl pkeyutl -verify -rawin, mit und ohne -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

Die meisten Ed25519-Verifizierer (JOSE EdDSA, SSH, minisign) implementieren nur reines Ed25519. Nutze ED25519_SHA_512 für kleine Eingaben wie die Signatureingabe eines JWT, und signiere bei Dateien ein Manifest, das den Hash der Datei enthält.

JWTs signieren: DER vs. r ‖ s

Ein JWS mit alg: ES256 trägt eine Signatur von genau 64 Byte: r und s, jeweils links auf 32 Byte aufgefüllt (RFC 7518 §3.4). AWS und Google Cloud liefern stattdessen die DER-Struktur SEQUENCE { INTEGER r, INTEGER s }. Der Testaufbau signiert ein JWT mit einem lokalen P-256-Schlüssel, der genau wie diese Dienste DER zurückgibt, und prüft es vor und nach der Umwandlung mit jose und jsonwebtoken:

jwt.mjs (Auszug): Umwandlung von DER in das JWS-Format (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)}`;
}
Ausgabe (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)

kmsSignDer ist hier sign("sha256", data, privateKey) aus node:crypto, das wie die Dienste DER zurückgibt. Drei Dinge solltest du dir merken:

  • jose lehnt DER ab, ohne einen Hinweis auf das Format zu geben; jsonwebtoken benennt das Problem: „ES256 signatures must be 64 bytes“. Die Verifizierer von Java melden „Invalid encoding for signature“, wenn sie das jeweils andere Format bekommen.
  • Die DER-Länge schwankt zwischen 68 und 72 Byte. Code, der die Bytes an einem festen Offset aufteilt oder von 70 Byte ausgeht, funktioniert in Tests und scheitert im Produktivbetrieb.
  • Etwa eine von 128 Signaturen hat ein r oder s, das kürzer als 32 Byte ist (in diesem Durchlauf 172 von 20.000). Ein Konverter, der das Auffüllen von links vergisst, erzeugt eine 63-Byte-Signatur, die manche Verifizierer ablehnen und andere falsch lesen – genau die Art sporadischer Bugs, die sich als „jede hundertste Anmeldung schlägt fehl“ zeigt. ECDSA-Signaturformate behandelt die Kodierungen im Detail.

Bei Azure Key Vault sind die Bytes in SignResult.result bereits r ‖ s und gehen direkt ins JWS. Die umgekehrte Umwandlung brauchst du dann für Verifizierer, die DER erwarten, etwa SHA256withECDSA in Java oder OpenSSL.

Der Parameter kid sollte die Schlüsselversion nennen, nicht nur den Schlüssel, damit Verifizierer während einer Rotation den richtigen öffentlichen Schlüssel auswählen können. Veröffentliche den öffentlichen Schlüssel als JWK: GetPublicKey bei AWS und GetPublicKey bei Google Cloud liefern SPKI (DER oder PEM), Azure liefert einen JWK. Wie du rotierst, ohne dass Verifizierer scheitern, zeigt JWKS-Schlüsselrotation.

Die drei Dienste aufrufen

Die drei Funktionen unten signieren den SHA-256-Digest beliebiger Daten und geben die Bytes zurück, die der jeweilige Dienst liefert. Typgeprüft wurden sie mit tsc --strict (TypeScript 5.9.3) gegen @aws-sdk/client-kms 3.1153.0, @google-cloud/kms 6.3.0, @azure/keyvault-keys 4.10.2 und @azure/identity 4.14.0; gegen die Dienste selbst liefen sie nicht, deshalb zeigen wir keine Ausgabe.

kms.ts (typgeprüft, nicht gegen die Cloud-Dienste ausgeführt)
// 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;
}

Die Hilfsfunktion für CRC32C lieferte für die Eingabe "123456789" den Wert 0xe3069283, den veröffentlichten Prüfwert von CRC-32C. Die Anleitung zum Signieren von Google Cloud empfiehlt die drei gezeigten Prüfungen: verifiedDigestCrc32c bestätigt, dass die Anfrage unversehrt angekommen ist, name, dass die richtige Schlüsselversion signiert hat, und signatureCrc32c, dass die Antwort unversehrt angekommen ist.

Die rohe Ausgabe von Azure ist das Format der darunterliegenden Windows-CNG-APIs; das Azure-SDK-Team beschrieb es 2018 als „‚raw‘-Format (dasselbe, das die Windows-APIs bcrypt und ncrypt verwenden)“ (azure-keyvault-java #58). Das ist bequem für JOSE und unbequem für alles, was X.509 spricht.

Lokal prüfen, nicht mit dem Dienst

Alle drei Dienste bieten einen Aufruf Verify an, und bei allen dreien kannst du den öffentlichen Schlüssel herunterladen. Nimm die zweite Möglichkeit: Die Prüfung mit dem öffentlichen Schlüssel kostet nichts, hat kein Kontingent und keine Abhängigkeit vom Netzwerk, und genau das tun deine Relying Parties ohnehin. Prüft dein eigener Test nur über Verify des Dienstes, kann er eine Formatabweichung verdecken, die jeder andere Verifizierer bemerkt. AWS sagt dasselbe über seinen Bedingungsschlüssel kms:SigningAlgorithm: Er „hat keine Auswirkung auf Operationen außerhalb von AWS KMS, etwa das Prüfen von Signaturen mit dem öffentlichen Schlüssel“.

Kontingente sind ein weiterer Grund. AWS KMS erlaubt 1.000 Anfragen pro Sekunde, die sich Sign und Verify über alle ECC-Schlüssel eines Kontos und einer Region teilen, und dasselbe noch einmal für RSA- und für ML-DSA-Schlüssel (Anfragekontingente, anpassbar). Ein Dienst, der jedes Sitzungstoken über KMS signiert, stößt früher an diese Grenze als einer, der ein paar Releases am Tag signiert; signiere, wo es geht, kurzlebige Datenschlüssel oder bündle Anfragen.

Berechtigungen: ein Schlüssel, nur signieren

Die sinnvolle Einheit einer Berechtigung ist „dieser Workload darf mit diesem Schlüssel signieren“, mehr nicht:

  • AWS KMS: Erlaube kms:Sign für den ARN des Schlüssels, in der Schlüsselrichtlinie oder einer IAM-Richtlinie, und schränke das mit Bedingungsschlüsseln ein: kms:SigningAlgorithm legt den Algorithmus fest, und kms:MessageType kann DIGEST verbieten, wenn nur kleine Nachrichten signiert werden sollen (mit einem Digest kann der Aufrufer beliebige Daten signieren). kms:GetPublicKey lässt sich Verifizierern getrennt gewähren. Kontoübergreifende Nutzung ist mit dem ARN des Schlüssels möglich.
  • Google Cloud KMS: Vergib roles/cloudkms.signer („Ermöglicht Sign-Vorgänge“, Berechtigung cloudkms.cryptoKeyVersions.useToSign) für den einzelnen CryptoKey und roles/cloudkms.publicKeyViewer an alle, die nur den öffentlichen Schlüssel abrufen. roles/cloudkms.signerVerifier vereint Signieren, Prüfen und Zugriff auf den öffentlichen Schlüssel.
  • Azure Key Vault: Mit Azure RBAC (Standard für Vaults, die mit der API-Version 2026-02-01 oder später erstellt wurden) erlaubt „Key Vault Crypto User“ kryptografische Operationen mit Schlüsseln, „Key Vault Crypto Officer“ verwaltet sie zusätzlich. Für reines Signieren erstellst du eine benutzerdefinierte Rolle mit der Datenaktion Microsoft.KeyVault/vaults/keys/sign/action; die integrierte Rolle Crypto User kann auch verschlüsseln, entschlüsseln und Schlüssel umschließen. Microsoft empfiehlt einen Vault pro Anwendung und Umgebung.

Releases, Tokens und Webhooks sollten mit drei getrennten Schlüsseln signiert werden, jeder mit eigenen Principals. Eine Rotation betrifft dann immer nur ein abhängiges System.

Audit-Logs sind nicht immer eingeschaltet

Ein Signaturschlüssel in einem Cloud-Dienst ist nur so nachvollziehbar wie das Log seiner Verwendung:

  • AWS CloudTrail zeichnet KMS-Aufrufe, einschließlich Sign, standardmäßig als Verwaltungsereignisse auf; der Log-Eintrag enthält Schlüssel, Aufrufer, Algorithmus und Nachrichtentyp, aber weder die Nachricht noch die Signatur. Ein Trail kann KMS-Ereignisse ganz ausschließen (ExcludeManagementEventSources); fehlen KMS-Einträge, such nach einem Ereignis PutEventSelectors.
  • Google Cloud stuft AsymmetricSign als Data-Access-Operation (DATA_READ) ein, und „Data-Access-Audit-Logs sind standardmäßig für alle Dienste außer einigen BigQuery-Diensten deaktiviert“ (Cloud Logging). Schaltest du sie für Cloud KMS nicht ein, gibt es keine Aufzeichnung der Signaturen.
  • Azure Key Vault schreibt Einträge vom Typ AuditEvent (Operationsname KeySign) erst, wenn du Diagnoseeinstellungen konfigurierst, die sie an ein Speicherkonto oder einen Log-Analytics-Arbeitsbereich senden.

Für Codesignatur ist das nicht nur gute Praxis: Seit Juni 2023 erlauben die Code Signing Baseline Requirements des CA/Browser Forums (§6.2.7.4.1) einen Cloud-Schlüsseldienst für öffentlich vertrauenswürdige Codesignaturschlüssel nur, wenn das Abonnement so „konfiguriert ist, dass alle Zugriffe, Vorgänge und Konfigurationsänderungen an den Ressourcen, die den privaten Schlüssel schützen, protokolliert werden“. Authenticode-Codesignatur behandelt diesen Anwendungsfall.

Häufige Fehler

  • DER in einem JWT oder r ‖ s, das an einen DER-Verifizierer geht. Prüfe die Länge: 64 Byte bei ES256, 68 bis 72 Byte mit 0x30 am Anfang bei DER. Der Konverter DER ⇄ Raw wandelt eine einzelne Signatur in beide Richtungen um.
  • Umwandlung ohne Auffüllen, die bei 99 % der Signaturen funktioniert.
  • Doppeltes Hashing: ein Digest, der als Nachricht gesendet wird.
  • Ed25519ph, wo Ed25519 erwartet wurde, oder umgekehrt.
  • High-S-Signaturen auf Blockchains. ECDSA-Signaturen von secp256k1-Schlüsseln in AWS KMS brauchen eine Low-S-Normalisierung und eine Recovery-ID, bevor Ethereum sie akzeptiert; ECDSA-Public-Key-Recovery zeigt, wie das geht.
  • Tests, die nur über den Dienst prüfen. Lade den öffentlichen Schlüssel herunter und prüfe so, wie es eine Relying Party tun wird.
  • Weit gefasste Rollen und fehlende Logs. Mit einer Rolle, die mit jedem Schlüssel eines Projekts signieren kann, und ohne Data-Access-Log bleibt keine Spur davon, was signiert wurde.
  • Die Annahme, ein Schlüssel ließe sich umziehen. In diesen Diensten erzeugte Schlüssel sind nicht exportierbar; eine Migration zu einem anderen Anbieter bedeutet einen neuen Schlüssel und eine Rotation, die du veröffentlichst.

Quellen