要点:3 つのサービスはいずれも、秘密鍵を HSM またはソフトウェアモジュール内に保管し、送られてきたデータに署名します。連携を壊す違いは小さく、具体的なものです。AWS KMS と Google Cloud KMS は ECDSA 署名を DER エンコードで返します。JWT には 64 バイトの r ‖ s 形式が必要なので、base64url エンコードする前に変換してください。Azure Key Vault は最初から r ‖ s を返します。数キロバイトを超えるデータにはダイジェストを送り、それがダイジェストであることをサービスに伝えてください(AWS では MessageType: DIGEST、Google Cloud では digest フィールド。Azure は常にダイジェストを想定しています)。伝えないと二重にハッシュされます。呼び出し元には 1 つの鍵に限定した署名専用の権限を付与し、デフォルトで無効になっている監査ログ(Google Cloud のデータアクセス監査ログ、Azure の診断設定)を有効にし、署名はサービスを呼び出すのではなく、公開鍵を使ってオフラインで検証してください。
このページのクラウド呼び出しは、コンテナー内では実行できませんでした。そのコードはバージョンを固定した SDK に対して tsc --strict で型チェックし、形式に関する問題はすべて、サービスと同じように振る舞うローカルの鍵(同じハッシュ、同じ出力エンコーディング)で再現しました。
3 つのサービスの比較
| 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 | 対応(純粋な Ed25519 と事前ハッシュ方式) | 対応(純粋な Ed25519、ソフトウェア鍵のみ) | 非対応 |
| RSA | 2048、3072、4096。PSS と PKCS #1 v1.5 | 2048、3072、4096。PSS、PKCS #1 v1.5、生の PKCS #1 | 2048、3072、4096。PSS と PKCS #1 v1.5 |
| ポスト量子暗号 | ML-DSA-44/65/87 | ML-DSA-44/65/87、SLH-DSA-SHA2-128s(ソフトウェア鍵) | なし |
| 鍵の保護 | FIPS 140-3 レベル 3 の HSM | ソフトウェア(FIPS 140-3 L1 のプリミティブ)または Cloud HSM(FIPS 140-2 L3) | ソフトウェア(FIPS 140-2 L1)または HSM(HSM プラットフォーム 2 では FIPS 140-3 L3、Premium) |
出典:AWS の鍵仕様リファレンスと Sign、Google Cloud のアルゴリズムと保護レベル、Azure の鍵の種類とアルゴリズム。これらのページにある 2 つの点が、後で重要になります。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 値を想定しています。
KMS の鍵の代わりにローカルの P-256 鍵を使い、二重ハッシュの誤りを OpenSSL 3.6.5 で再現すると、次のようになります。
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
2 つ目の署名は有効ですが、ファイルに対する署名ではありません。ダイジェストのファイルをメッセージとして署名しているからです。標準的な検証者はどれもファイルを 1 回だけハッシュするため、検証は失敗します。修正に必要なのはパラメーター 1 つだけで、だからこそこのバグはコードレビューをすり抜けてしまいます。
Ed25519 にも、独自の形で同じ問題があります。AWS KMS は ECC_NIST_EDWARDS25519 鍵に 2 つのアルゴリズムを用意しています。ED25519_SHA_512(純粋な Ed25519。MessageType: RAW が必須なので最大 4096 バイト)と、ED25519_PH_SHA_512(RFC 8032 の Ed25519ph。DIGEST が必須で、それでも AWS は独自に SHA-512 の事前ハッシュを適用するため、「入力は 2 回ハッシュされます。1 回は利用者が、もう 1 回は KMS が行います」)です。2 つのアルゴリズムが生成する署名は異なり、一方の署名をもう一方の方式で検証することはできません。
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 しか実装していません。JWT の署名入力のような小さな入力には ED25519_SHA_512 を使い、ファイルの場合は、ファイルのハッシュを含むマニフェストに署名してください。
JWT への署名:DER と r ‖ s
alg: ES256 の JWS が持つ署名はちょうど 64 バイトで、r と s をそれぞれ先頭からゼロでパディングして 32 バイトにしたものです(RFC 7518 §3.4)。一方、AWS と Google Cloud が返すのは DER の SEQUENCE { INTEGER r, INTEGER s } です。実験環境では、これらのサービスとまったく同じように DER を返すローカルの P-256 鍵で JWT に署名し、変換の前後で 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 は node:crypto の sign("sha256", data, privateKey) で、これらのサービスと同じく DER を返します。覚えておくべき点は 3 つです。
- jose は形式について何のヒントも示さずに DER を拒否しますが、jsonwebtoken は ES256 signatures must be 64 bytes と形式を明示します。Java の検証者は、逆の形式を渡されると Invalid encoding for signature と報告します。
- DER の長さは 68〜72 バイトの範囲で変わります。固定のオフセットで切り出すコードや、70 バイトを前提とするコードは、テストでは動いても本番環境で失敗します。
rまたはsが 32 バイトより短くなる署名は、約 128 個に 1 個あります(今回の実行では 20,000 個中 172 個)。左パディングを忘れたコンバーターは 63 バイトの署名を生成し、それを拒否する検証者もあれば、誤って解釈する検証者もあります。「ログインが 100 回に 1 回失敗する」という形で現れる、断続的なバグの典型です。エンコーディングの詳細は ECDSA 署名の形式で解説しています。
Azure Key Vault の場合、SignResult.result のバイト列はすでに r ‖ s なので、そのまま JWS に入れられます。その代わり、Java の SHA256withECDSA や OpenSSL のように DER を想定する検証者に渡すには、逆方向の変換が必要です。
ローテーション中に検証者が正しい公開鍵を選べるよう、kid には鍵だけでなく鍵のバージョンまで示すべきです。公開鍵は JWK として公開してください。AWS の GetPublicKey と Google Cloud の GetPublicKey は SPKI(DER または PEM)を返し、Azure は JWK を返します。検証者を壊さずにローテーションする方法は、ダウンタイムなしの JWKS 鍵ローテーションで扱っています。
各サービスの呼び出し
次の 3 つの関数は、データの SHA-256 ダイジェストに署名し、各サービスが返すバイト列をそのまま返します。@aws-sdk/client-kms 3.1153.0、@google-cloud/kms 6.3.0、@azure/keyvault-keys 4.10.2、@azure/identity 4.14.0 に対して tsc --strict(TypeScript 5.9.3)で型チェックしましたが、実際のサービスに対しては実行していないため、出力は掲載していません。
// 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 のヘルパーは、入力 "123456789" に対して 0xe3069283 を返しました。これは CRC-32C の公表されているチェック値です。Google Cloud の署名ガイドは、ここで示した 3 つのチェックを推奨しています。verifiedDigestCrc32c はリクエストが壊れずに届いたこと、name は正しい鍵のバージョンが署名したこと、signatureCrc32c はレスポンスが壊れずに届いたことを示します。
Azure の生形式の出力は、その下で動いている Windows の CNG API の形式です。Azure SDK チームは 2018年に、これを「『raw』形式(Windows の bcrypt および ncrypt API と同じもの)」と説明しています(azure-keyvault-java #58)。JOSE には便利ですが、X.509 を扱うあらゆるものにとっては不便です。
サービスではなくローカルで検証する
3 つのサービスはどれも Verify 呼び出しを提供しており、どれも公開鍵をダウンロードできます。後者を選んでください。公開鍵による検証には費用がかからず、クォータもネットワークへの依存関係もありません。それに、リライングパーティもいずれにせよその方法で検証します。自分のテストでサービスの Verify を通してしか検証していないと、ほかのすべての検証者が気づく形式の不一致を見逃すおそれがあります。AWS も kms:SigningAlgorithm 条件キーについて同じことを述べており、この条件キーは「公開鍵による署名の検証など、AWS KMS の外部で実行されるオペレーションには影響しません」としています。
クォータも理由の 1 つです。AWS KMS が許可するのは 1 秒あたり 1,000 件のリクエストで、この上限は、アカウントとリージョンごとに、すべての ECC 鍵に対する Sign と Verify で共有されます。RSA 鍵と ML-DSA 鍵にも、それぞれ同じ上限が別にあります(リクエストクォータ、引き上げ可能)。すべてのセッショントークンを KMS で署名するサービスは、1 日に数回リリースに署名するだけのサービスよりも早くこの上限に達します。可能な場合は、有効期間の短いデータ鍵に署名するか、バッチで処理してください。
権限:1 つの鍵、署名のみ
役に立つ権限の単位は「このワークロードはこの鍵で署名してよい」というものであり、それ以上は必要ありません。
- AWS KMS:キーポリシーまたは IAM ポリシーで、鍵の ARN に対する
kms:Signを許可し、条件キーで制限します。kms:SigningAlgorithmはアルゴリズムを固定し、kms:MessageTypeは、小さなメッセージだけに署名させたい場合にDIGESTを禁止できます(ダイジェストを使うと、呼び出し元は任意のデータに署名できるためです)。kms:GetPublicKeyは検証者に別途付与できます。鍵の ARN を使えば、クロスアカウントでの利用も可能です。 - Google Cloud KMS:単一の CryptoKey に
roles/cloudkms.signer(「署名オペレーションを有効にします」、権限はcloudkms.cryptoKeyVersions.useToSign)を付与し、公開鍵の取得だけを行う側には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 は、アプリケーションと環境ごとに 1 つのキーコンテナーを使うことを推奨しています。
リリースに署名する鍵、トークンに署名する鍵、webhook に署名する鍵は、それぞれ独自のプリンシパルを持つ 3 つの別々の鍵にすべきです。そうすれば、ローテーションの影響は一度に 1 つの利用側にしか及びません。
監査ログは常に有効とは限らない
クラウドサービスの署名鍵について、何にどう使われたかを後から説明できるかどうかは、その使用ログ次第です。
- AWS CloudTrail は、
Signを含む KMS の呼び出しを、デフォルトで管理イベントとして記録します。ログエントリには鍵、呼び出し元、アルゴリズム、メッセージタイプが含まれますが、メッセージや署名は含まれません。証跡は KMS イベントを完全に除外するよう設定できるため(ExcludeManagementEventSources)、KMS のエントリが見当たらない場合はPutEventSelectorsイベントを探してください。 - Google Cloud は
AsymmetricSignをデータアクセス(DATA_READ)オペレーションに分類しており、「データアクセス監査ログは、一部の BigQuery サービスを除くすべてのサービスで、デフォルトで無効になっています」(Cloud Logging)。Cloud KMS に対してこのログを有効にしない限り、署名の記録は残りません。 - Azure Key Vault は、ストレージアカウントまたは Log Analytics ワークスペースに送信するよう診断設定を構成した後でなければ、
AuditEventレコード(オペレーション名KeySign)を書き込みません。
コード署名では、これは単なるベストプラクティスにとどまりません。2023年6月以降、CA/Browser Forum のコード署名ベースライン要件(§6.2.7.4.1)は、公的に信頼されるコード署名鍵にクラウドの鍵サービスを使うことを、サブスクリプションが「秘密鍵を保護するリソースに対するすべてのアクセス、操作、設定変更を記録するよう構成されている」場合に限って認めています。この用途については Authenticode コード署名で解説しています。
よくある間違い
- JWT に入れた DER、または DER の検証者に渡した
r ‖ s:長さを確認してください。ES256 では 64 バイト、DER では0x30で始まる 68〜72 バイトです。DER ⇄ 生形式の変換ツールで、1 つの署名を双方向に変換できます。 - パディングしない変換:99% の署名では動いてしまう変換です。
- 二重ハッシュ:ダイジェストをメッセージとして送ってしまう誤りです。
- Ed25519 を想定した箇所で Ed25519ph を使う、またはその逆の誤りです。
- ブロックチェーンでの high-S 署名:AWS KMS の secp256k1 鍵による ECDSA 署名を Ethereum に受け入れさせるには、low-S 正規化とリカバリ ID が必要です。方法は ECDSA 公開鍵の復元で説明しています。
- テストでサービス経由の検証しか行わない:公開鍵をダウンロードし、リライングパーティと同じ方法で検証してください。
- 広すぎるロールとログの欠如:プロジェクト内のすべての鍵で署名できるロールがあり、データアクセス監査ログもないと、何が署名されたかの痕跡が残りません。
- 鍵を移動できると思い込む:これらのサービスで生成した鍵はエクスポートできません。別のプロバイダーに移行するには、新しい鍵を作成し、ローテーションを公開する必要があります。
出典
- AWS KMS:
SignAPI リファレンス、鍵仕様リファレンス、条件キー、リクエストクォータ、CloudTrail によるログ記録 - Google Cloud KMS:アルゴリズム、署名の作成と検証、ロール、監査ログ
- Azure Key Vault:鍵の種類とアルゴリズム、
signREST API、Azure RBAC、ログ記録 - RFC 7518 §3.4(JWS における ECDSA)、RFC 3279 §2.2.3(DER における ECDSA)、RFC 8032 §5.1(Ed25519 と Ed25519ph)
- CA/Browser Forum コード署名ベースライン要件 3.11.0、§6.2.7.4