Коротко. Все три службы хранят закрытый ключ в своих HSM или программных модулях и подписывают то, что вы им отправляете. Различия, из-за которых ломаются интеграции, невелики и вполне конкретны. AWS KMS и Google Cloud KMS возвращают подписи ECDSA в кодировке DER, а для JWT нужна 64-байтная форма r ‖ s, поэтому преобразуйте подпись до кодирования в base64url. Azure Key Vault сразу возвращает r ‖ s. Для всего, что больше нескольких килобайт, отправляйте дайджест и сообщайте службе, что это дайджест (MessageType: DIGEST в AWS, поле digest в Google Cloud; Azure всегда ожидает дайджест), иначе данные будут хешированы дважды. Выдавайте вызывающим сторонам разрешение только на подпись и только для одного ключа, включите журналы аудита, которые по умолчанию выключены (журналы Data Access в Google Cloud, параметры диагностики в Azure), и проверяйте подписи офлайн открытым ключом, а не вызовом службы.
Обращения к облачным службам на этой странице невозможно было выполнить в контейнере. Их код прошёл проверку типов в tsc --strict с зафиксированными версиями SDK, а каждая проблема с форматом воспроизведена на локальном ключе, который ведёт себя как служба: тот же хеш, та же кодировка результата.
Сравнение трёх служб
| AWS KMS | Google Cloud KMS | Azure Key Vault | |
|---|---|---|---|
| Вызов подписи | Sign | AsymmetricSign | sign (REST keys/{name}/{version}/sign) |
| Входные данные | сообщение до 4096 байт или дайджест (MessageType RAW, DIGEST, EXTERNAL_MU) | digest или data, но не оба сразу | всегда дайджест |
| Формат подписи ECDSA | DER (RFC 3279) | DER | r ‖ s (формат RFC 7518) |
| Ключи EC | P-256, P-384, P-521, secp256k1 | P-256, P-384, secp256k1 (только HSM) | P-256, P-256K (secp256k1), P-384, P-521 |
| Ed25519 | да, чистый и с предварительным хешированием | да, чистый (только программные ключи) | нет |
| RSA | 2048, 3072, 4096; PSS и PKCS #1 v1.5 | 2048, 3072, 4096; PSS, PKCS #1 v1.5 и raw PKCS #1 | 2048, 3072, 4096; PSS и PKCS #1 v1.5 |
| Постквантовые алгоритмы | ML-DSA-44/65/87 | ML-DSA-44/65/87, SLH-DSA-SHA2-128s (программные ключи) | нет |
| Защита ключей | HSM уровня 3 по FIPS 140-3 | программная (примитивы FIPS 140-3 L1) или Cloud HSM (FIPS 140-2 L3) | программная (FIPS 140-2 L1) или HSM (FIPS 140-3 L3 на платформе HSM 2, Premium) |
Источники: у AWS — справочник по спецификациям ключей и Sign; у Google Cloud — алгоритмы и уровни защиты; у Azure — типы ключей и алгоритмы. Две детали с этих страниц понадобятся дальше: Google Cloud KMS для secp256k1 «формирует подписи только в „нормализованной“ форме (также известной как „форма lower-S“)», а AWS KMS такой гарантии в документации не даёт.
Дайджест или сообщение
Создание подписи ECDSA, RSA и ML-DSA начинается с хеширования входных данных. KMS может сделать это за вас, но AWS принимает не более 4096 байт сообщения, а Azure принимает только дайджесты, поэтому на практике данные хешируют локально и отправляют 32-байтное значение SHA-256. Тогда служба должна пропустить собственный шаг хеширования, и об этом нужно ей сообщить:
- AWS KMS:
MessageType: "DIGEST". Справочник API предупреждает, что дайджест, отправленный с типомRAW, «может привести к ошибкам проверки, если подпись проверяет система, которая рассчитывает на однократное хеширование». - Google Cloud KMS: передайте значение в
digest.sha256(или вsha384, в соответствии с алгоритмом ключа). Для алгоритмов, которые принимают исходные данные без хеширования, например Ed25519, обязательно полеdata. - Azure Key Vault: операция так и описана: «создаёт подпись по дайджесту»; для
ES256нужно 32-байтное значение SHA-256.
Вот как выглядит ошибка двойного хеширования; мы воспроизвели её с OpenSSL 3.6.5 и локальным ключом P-256 вместо ключа 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
Вторая подпись верна, но не для самого файла: подписан файл с дайджестом, как если бы он был сообщением. Любой стандартный код проверки хеширует сам файл один раз, поэтому проверка не проходит. Исправление — всего один параметр, и именно поэтому такая ошибка проходит ревью кода незамеченной.
У Ed25519 есть свой вариант этой проблемы. Для ключей ECC_NIST_EDWARDS25519 AWS KMS предлагает два алгоритма: ED25519_SHA_512 (чистый Ed25519, требует MessageType: RAW, поэтому не более 4096 байт) и ED25519_PH_SHA_512 (Ed25519ph из RFC 8032, требует DIGEST, причём AWS всё равно выполняет собственное предварительное хеширование SHA-512: «входные данные хешируются дважды: один раз вами и один раз в KMS»). Эти алгоритмы дают разные подписи, и подпись одного не проходит проверку как подпись другого:
pure signature, verified as Ed25519 Signature Verified Successfully
Ed25519ph signature, verified as Ed25519 Signature Verification Failure
Ed25519ph signature, verified as Ed25519ph Signature Verified Successfully
Большинство реализаций проверки Ed25519 (JOSE EdDSA, SSH, minisign) поддерживают только чистый Ed25519. Используйте ED25519_SHA_512 для небольших входных данных, например для подписываемой части JWT, а для файлов подписывайте манифест, который содержит хеш файла.
Подпись JWT: DER или r ‖ s
JWS с alg: ES256 содержит подпись длиной ровно 64 байта: r и s дополнены нулями слева, каждое до 32 байт (RFC 7518, § 3.4). AWS и Google Cloud вместо этого возвращают DER-структуру SEQUENCE { INTEGER r, INTEGER s }. На нашем стенде JWT подписывается локальным ключом P-256, который возвращает DER точно так же, как эти службы, а затем проверяется библиотеками jose и jsonwebtoken до и после преобразования:
/** 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)
Здесь kmsSignDer — это sign("sha256", data, privateKey) из node:crypto: эта функция, как и облачные службы, возвращает DER. Стоит запомнить три вещи:
- jose отвергает DER, никак не указывая на формат; jsonwebtoken прямо называет проблему: ES256 signatures must be 64 bytes. Код проверки в Java, получив подпись в обратном формате, сообщает Invalid encoding for signature.
- Длина DER колеблется от 68 до 72 байт. Код, который режет подпись по фиксированному смещению или рассчитывает на 70 байт, работает в тестах и ломается в рабочей среде.
- Примерно у одной подписи из 128 значение
rилиsкороче 32 байт (в этом прогоне — у 172 из 20 000). Конвертер, который забывает дополнить значение нулями слева, выдаёт 63-байтную подпись: одни библиотеки проверки её отвергают, другие неверно разбирают. Так появляется плавающая ошибка вида «не проходит один вход из ста». Кодировки подробно разобраны в статье Форматы подписей ECDSA.
В Azure Key Vault байты в SignResult.result уже имеют вид r ‖ s и сразу идут в JWS. Зато обратное преобразование понадобится там, где код проверки ожидает DER, например в SHA256withECDSA из Java или в OpenSSL.
В kid следует указывать версию ключа, а не только сам ключ, чтобы во время ротации проверяющие стороны могли выбрать нужный открытый ключ. Публикуйте открытый ключ в виде JWK: GetPublicKey в AWS и GetPublicKey в Google Cloud возвращают SPKI (DER или PEM), а Azure возвращает JWK. Как провести ротацию, не сломав проверку, рассказано в статье Ротация ключей JWKS.
Обращение к каждой службе
Три функции ниже подписывают дайджест SHA-256 произвольных данных и возвращают байты в том виде, в каком их отдаёт служба. Их типы проверены в tsc --strict (TypeScript 5.9.3) с пакетами @aws-sdk/client-kms 3.1153.0, @google-cloud/kms 6.3.0, @azure/keyvault-keys 4.10.2 и @azure/identity 4.14.0; с самими службами функции не запускались, поэтому вывода здесь нет.
// 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;
}
Вспомогательная функция CRC32C вернула 0xe3069283 для входных данных "123456789" — это опубликованное контрольное значение CRC-32C. Руководство Google Cloud по подписи рекомендует три показанные проверки: verifiedDigestCrc32c подтверждает, что запрос дошёл без искажений, name — что подписала нужная версия ключа, а signatureCrc32c — что без искажений дошёл ответ.
Формат raw, который выдаёт Azure, — это формат API Windows CNG, на которых построена служба; команда Azure SDK в 2018 году описала его как «формат „raw“ (тот же, что используют API bcrypt и ncrypt в Windows)» (azure-keyvault-java #58). Для JOSE это удобно, а для всего, что работает с X.509, — нет.
Проверяйте локально, а не через службу
Все три службы предлагают вызов Verify, и все три позволяют скачать открытый ключ. Выбирайте второе: проверка открытым ключом ничего не стоит, не ограничена квотами и не создаёт зависимости от сети; к тому же именно так всё равно будут проверять подписи ваши доверяющие стороны. Если ваш тест проверяет подпись только через Verify службы, он может скрыть несоответствие формата, которое заметит любая другая проверяющая сторона. AWS пишет то же самое о своём ключе условия kms:SigningAlgorithm: он «не влияет на операции, выполняемые за пределами AWS KMS, например на проверку подписей открытым ключом».
Ещё одна причина — квоты. AWS KMS допускает 1 000 запросов в секунду, общих для Sign и Verify по всем ключам ECC одного аккаунта в одном регионе, и ещё столько же для ключей RSA и для ключей ML-DSA (квоты на запросы, их можно изменить). Приложение, которое подписывает через KMS каждый токен сессии, упрётся в этот потолок раньше, чем то, что подписывает несколько релизов в день; где возможно, подписывайте в KMS короткоживущие ключи данных или объединяйте данные в пакеты.
Права доступа: один ключ, только подпись
Разумная единица прав — «эта рабочая нагрузка может подписывать этим ключом», и ничего сверх этого:
- AWS KMS: разрешите
kms:Signдля ARN ключа в политике ключа или в политике IAM и ограничьте разрешение ключами условий:kms:SigningAlgorithmфиксирует алгоритм, аkms:MessageTypeможет запретитьDIGEST, если подписывать нужно только небольшие сообщения (дайджест позволяет вызывающей стороне подписать любые данные). Разрешениеkms:GetPublicKeyможно отдельно выдать проверяющим сторонам. Ключ можно использовать и из другого аккаунта: для этого указывают ARN ключа. - Google Cloud KMS: выдайте роль
roles/cloudkms.signer(«разрешает операции подписи», разрешениеcloudkms.cryptoKeyVersions.useToSign) на один конкретный CryptoKey, аroles/cloudkms.publicKeyViewer— тем, кто только получает открытый ключ.roles/cloudkms.signerVerifierобъединяет подпись, проверку и доступ к открытому ключу. - Azure Key Vault: при Azure RBAC (это вариант по умолчанию для хранилищ, созданных через API версии 2026-02-01 и новее) роль «Key Vault Crypto User» разрешает криптографические операции с ключами, а «Key Vault Crypto Officer» — ещё и управление ими. Чтобы разрешить только подпись, создайте настраиваемую роль с действием над данными
Microsoft.KeyVault/vaults/keys/sign/action: встроенная роль Crypto User позволяет также шифровать, расшифровывать и упаковывать ключи. Microsoft рекомендует отдельное хранилище для каждого приложения и каждой среды.
Для подписи релизов, токенов и вебхуков следует использовать три разных ключа, и у каждого — свои принципалы. Тогда ротация каждого ключа затрагивает только одного потребителя.
Журналы аудита включены не всегда
Ключ подписи в облачной службе подотчётен ровно настолько, насколько полон журнал его использования:
- AWS CloudTrail по умолчанию записывает вызовы KMS, в том числе
Sign, как события управления; в записи журнала есть ключ, вызывающая сторона, алгоритм и тип сообщения, но нет ни сообщения, ни подписи. Журнал CloudTrail (trail) может полностью исключать события KMS (ExcludeManagementEventSources); если записей KMS нет, ищите событиеPutEventSelectors. - Google Cloud относит
AsymmetricSignк операциям Data Access (DATA_READ), а «журналы аудита Data Access по умолчанию отключены для всех служб, кроме некоторых служб BigQuery» (Cloud Logging). Пока эти журналы не включены для Cloud KMS, никаких записей о подписях у вас нет. - Azure Key Vault пишет записи
AuditEvent(имя операцииKeySign) только после того, как вы настроите в параметрах диагностики их отправку в учётную запись хранения или рабочую область Log Analytics.
Для подписи кода это не просто хорошая практика: с июня 2023 года Базовые требования к подписи кода (Code Signing Baseline Requirements) CA/Browser Forum (§ 6.2.7.4.1) разрешают хранить публично доверенные ключи подписи кода в облачной службе ключей, только если подписка «настроена так, чтобы журналировать все обращения, операции и изменения конфигурации на ресурсах, которые защищают закрытый ключ». Этот сценарий разобран в статье Подпись кода Authenticode.
Типичные ошибки
- DER в JWT или подпись
r ‖ s, переданная коду проверки, который ожидает DER. Проверьте длину: 64 байта для ES256, от 68 до 72 байт с первым байтом0x30для DER. Конвертер DER в raw преобразует отдельную подпись в обе стороны. - Преобразование без дополнения нулями, которое работает для 99 % подписей.
- Двойное хеширование: дайджест отправлен как сообщение.
- Ed25519ph там, где ожидался Ed25519, или наоборот.
- Подписи с high-S в блокчейнах. Подписям ECDSA от ключей secp256k1 в AWS KMS нужны нормализация low-S и идентификатор восстановления, иначе Ethereum их не примет; как это сделать, показано в статье Восстановление открытого ключа ECDSA.
- Проверка в тестах только через службу. Скачайте открытый ключ и проверяйте подпись так же, как это будет делать доверяющая сторона.
- Широкие роли и отсутствие журналов. Если роль может подписывать любым ключом проекта, а журнала Data Access нет, никаких следов того, что было подписано, не остаётся.
- Расчёт на то, что ключ можно перенести. Ключи, сгенерированные в этих службах, экспортировать нельзя; миграция к другому провайдеру означает новый ключ и опубликованную ротацию.
Источники
- AWS KMS: справочник по API
Sign, справочник по спецификациям ключей, ключи условий, квоты на запросы, журналирование в CloudTrail - Google Cloud KMS: алгоритмы, создание и проверка подписей, роли, журналы аудита
- Azure Key Vault: типы ключей и алгоритмы, REST API
sign, Azure RBAC, журналирование - RFC 7518, § 3.4 (ECDSA в JWS), RFC 3279, § 2.2.3 (ECDSA в DER), RFC 8032, § 5.1 (Ed25519 и Ed25519ph)
- Базовые требования CA/Browser Forum к подписи кода, версия 3.11.0, § 6.2.7.4