跳到主要内容
ecdsa.com
← 全部文章

用云 KMS 签名:AWS、Google Cloud 和 Azure

私钥从不离开服务,因此签名 API 的每一处特殊行为都得由你来应对:发送的是什么、哈希怎么算、返回的是哪种编码。我们于2026年10月20日核对了各服务商的文档,并在本地复现了每一个格式陷阱。

简短回答:这三个服务都把私钥保存在自己的 HSM 或软件模块中,并对你发送的内容签名。导致集成出问题的差异很小,也很具体。AWS KMS 和 Google Cloud KMS 返回的 ECDSA 签名是 DER 编码的;JWT 需要 64 字节的 r ‖ s 形式,因此要先转换,再做 base64url 编码。Azure Key Vault 返回的已经是 r ‖ s。超过几 KB 的内容应发送其摘要,并告诉服务这是摘要(AWS 中为 MessageType: DIGEST,Google Cloud 中为 digest 字段;Azure 始终要求传入摘要),否则内容会被哈希两次。只为调用方授予单个密钥上仅限签名的权限,开启默认关闭的审计日志(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,不能同时提供始终为摘要
ECDSA 输出DER(RFC 3279)DERr ‖ s(RFC 7518 格式)
EC 密钥P-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、原始 PKCS #12048、3072、4096;PSS 和 PKCS #1 v1.5
后量子ML-DSA-44/65/87ML-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 密钥类型和算法。这些页面中有两个细节在后文很重要:对于 secp256k1,Google Cloud KMS 的文档写明它“只生成‘规范化’形式的签名(也称为‘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 MB 的文件签名,然后演示双重哈希错误
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

第二个签名本身是有效的,只是签的不是这个文件:它把摘要文件当作消息来签名。所有标准验证器都会对文件做一次哈希,所以验证失败。修复只差一个参数,这正是这个 bug 能躲过代码审查的原因。

Ed25519 也有自己版本的同类问题。对于 ECC_NIST_EDWARDS25519 密钥,AWS KMS 提供两种算法:ED25519_SHA_512(纯 Ed25519,要求 MessageType: RAW,因此最多 4096 字节)和 ED25519_PH_SHA_512(RFC 8032 中的 Ed25519ph,要求 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。对 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 验证:

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 就是 node:crypto 中的 sign("sha256", data, privateKey),它和这些服务一样返回 DER。有三点值得记住:

  • jose 拒绝 DER 时不会提示是格式问题;jsonwebtoken 则会点明:ES256 signatures must be 64 bytes。反过来,Java 的验证器拿到另一种格式时会报 Invalid encoding for signature。
  • DER 的长度在 68 到 72 字节之间变化。按固定偏移量切分或假定长度为 70 字节的代码,在测试中能用,到了生产环境就会出错。
  • 大约每 128 个签名中就有一个的 r 或 s 短于 32 字节(本次运行的 20,000 个签名中有 172 个)。忘记左侧填充的转换器会生成 63 字节的签名,有些验证器会拒绝它,另一些则会误读,这就是那种表现为“百次登录总有一次失败”的间歇性 bug。ECDSA 签名格式一文详细介绍了这些编码。

对于 Azure Key Vault,SignResult.result 中的字节已经是 r ‖ s,可以直接放进 JWS。这时,对于只接受 DER 的验证器(例如 Java 的 SHA256withECDSA 或 OpenSSL),就需要做反方向的转换。

kid 应当指明密钥版本,而不只是密钥,这样验证方在轮换期间才能选出正确的公钥。以 JWK 形式发布公钥:AWS GetPublicKey 和 Google Cloud GetPublicKey 返回 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;
}

对于输入 "123456789",这个 CRC32C 辅助函数返回 0xe3069283,正是 CRC-32C 公布的校验值。Google Cloud 的签名指南建议做上面这三项检查:verifiedDigestCrc32c 表明请求完整送达,name 表明签名的是正确的密钥版本,signatureCrc32c 表明响应完整送达。

Azure 的原始输出就是其底层 Windows CNG API 所用的格式;Azure SDK 团队在2018年将其描述为“‘原始’格式(与 Windows bcrypt 和 ncrypt API 所用的相同)”(azure-keyvault-java #58)。这对 JOSE 很方便,对一切使用 X.509 的场景则很不方便。

在本地验证,而不是通过服务验证

这三个服务都提供 Verify 调用,也都允许下载公钥。应优先选择后者:用公钥验证不产生费用,没有配额限制,也不依赖网络,而且你的依赖方本来就是这样验证的。如果你自己的测试只经由服务的 Verify 验证,它就可能掩盖一个其他所有验证器都会发现的格式不匹配。AWS 对其 kms:SigningAlgorithm 条件键也有同样的说明:它“对在 AWS KMS 之外执行的操作(例如用公钥验证签名)没有影响”。

配额是另一个原因。AWS KMS 每秒允许 1,000 个请求,由同一账户、同一区域内所有 ECC 密钥的 Sign 和 Verify 共享;RSA 密钥和 ML-DSA 密钥也各有同样的配额(请求配额,可调整)。通过 KMS 为每个会话令牌签名的服务,会比每天只签几个发布版本的服务更早碰到这个上限;条件允许时,改为用 KMS 为短期数据密钥签名,或者批量签名。

权限:一个密钥,仅限签名

有用的权限单位是“这个工作负载可以用这个密钥签名”,仅此而已:

  • 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 建议每个应用、每个环境各用一个保管库。

为发布版本签名的密钥、为令牌签名的密钥和为 webhook 签名的密钥应当是三个不同的密钥,各有自己的主体(principal)。这样,每次轮换只影响一个使用方。

审计日志并不总是开启的

云服务中的签名密钥能否追责,完全取决于它的使用日志:

  • AWS CloudTrail 默认将 KMS 调用(包括 Sign)记录为管理事件;日志条目包含密钥、调用方、算法和消息类型,但不包含消息或签名。跟踪(trail)可以完全排除 KMS 事件(ExcludeManagementEventSources);如果缺少 KMS 条目,就查找 PutEventSelectors 事件。
  • Google Cloud 把 AsymmetricSign 归为数据访问(DATA_READ)操作,而“除部分 BigQuery 服务外,所有服务的数据访问审计日志默认都处于停用状态”(Cloud Logging)。如果不为 Cloud KMS 启用这些日志,就没有任何签名记录。
  • Azure Key Vault 只有在你配置诊断设置、把 AuditEvent 记录发送到存储账户或 Log Analytics 工作区之后,才会写入这些记录(操作名称为 KeySign)。

对于代码签名,这不只是良好实践:自2023年6月起,CA/Browser Forum 的《代码签名基线要求》(§6.2.7.4.1)只有在订阅“已配置为记录对保护私钥的资源的所有访问、操作和配置更改”时,才允许把云密钥服务用于公开受信任的代码签名密钥。Authenticode 代码签名一文介绍了这个用例。

常见错误

  • 把 DER 放进 JWT,或者把 r ‖ s 交给 DER 验证器。检查长度:ES256 为 64 字节;DER 为 68 到 72 字节,并以 0x30 开头。DER ⇄ 原始格式转换器可以在两个方向上转换单个签名。
  • 转换时不做填充,只对 99% 的签名有效。
  • 双重哈希:把摘要当作消息发送。
  • 该用 Ed25519 的地方用了 Ed25519ph,或者反过来。
  • 区块链上的 high-S 签名。AWS KMS secp256k1 密钥生成的 ECDSA 签名需要先做 low-S 规范化并补上恢复 ID,以太坊才会接受;ECDSA 公钥恢复一文介绍了做法。
  • 测试中只通过服务验证。下载公钥,按依赖方实际的方式验证。
  • 角色权限过宽、日志缺失。一个能用项目中所有密钥签名的角色,再加上没有数据访问日志,签过什么就无从追查。
  • 以为密钥可以转移。在这些服务中生成的密钥不可导出;迁移到另一家服务商,就意味着换用新密钥并对外公布轮换。

来源