Перейти к содержимому
ecdsa.com
← Все статьи

Подпись в облачных KMS: AWS, Google Cloud и Azure

Закрытый ключ никогда не покидает службу, поэтому каждая особенность API подписи становится вашей проблемой: что вы отправляете, что считается хешем и в какой кодировке возвращается подпись. Мы сверились с документацией каждого провайдера 20 октября 2026 года и локально воспроизвели каждую ловушку с форматами.

Коротко. Все три службы хранят закрытый ключ в своих 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 KMSGoogle Cloud KMSAzure Key Vault
Вызов подписиSignAsymmetricSignsign (REST keys/{name}/{version}/sign)
Входные данныесообщение до 4096 байт или дайджест (MessageType RAW, DIGEST, EXTERNAL_MU)digest или data, но не оба сразувсегда дайджест
Формат подписи ECDSADER (RFC 3279)DERr ‖ s (формат RFC 7518)
Ключи ECP-256, P-384, P-521, secp256k1P-256, P-384, secp256k1 (только HSM)P-256, P-256K (secp256k1), P-384, P-521
Ed25519да, чистый и с предварительным хешированиемда, чистый (только программные ключи)нет
RSA2048, 3072, 4096; PSS и PKCS #1 v1.52048, 3072, 4096; PSS, PKCS #1 v1.5 и raw PKCS #12048, 3072, 4096; PSS и PKCS #1 v1.5
Постквантовые алгоритмыML-DSA-44/65/87ML-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:

files.sh (фрагмент): подпись файла размером 10 МБ через его дайджест, затем ошибка двойного хеширования
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
Вывод (debian:forky-20261005-slim, OpenSSL 3.6.5)
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»). Эти алгоритмы дают разные подписи, и подпись одного не проходит проверку как подпись другого:

openssl pkeyutl -verify -rawin с параметром -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

Большинство реализаций проверки 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 до и после преобразования:

jwt.mjs (фрагмент): преобразование DER в формат 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)}`;
}
Вывод (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 — это 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; с самими службами функции не запускались, поэтому вывода здесь нет.

kms.ts (типы проверены, с облачными службами не запускался)
// 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 нет, никаких следов того, что было подписано, не остаётся.
  • Расчёт на то, что ключ можно перенести. Ключи, сгенерированные в этих службах, экспортировать нельзя; миграция к другому провайдеру означает новый ключ и опубликованную ротацию.

Источники