使用 WebCrypto 对数据进行签名和验证

Web 功能: Web Cryptography

使用 Web Cryptography API (crypto.subtle),通过抗量子 (ML-DSA) 或经典('Ed25519'、'ECDSA')非对称密钥在浏览器中对消息、令牌或工件进行签名和验证。数字签名可保证数据完整性和签名者真实性,而无需共享密钥。如需了解如何建立共享密钥和加密机密载荷,请参阅使用 WebCrypto 在客户端加密数据。

1. 选择算法并生成密钥对

ML-DSA(基于模块格的数字签名算法,在 NIST FIPS 204 中进行了标准化)提供基于格的后量子数字签名,可抵御传统计算机和量子计算机的攻击。

默认情况下,使用 'ML-DSA-65' 进行通用签名。如果您需要通过带宽受限的通道传输较小的公钥和签名,并且 128 位安全性已足够,请选择 'ML-DSA-44';如果安全政策要求符合 NIST 第 5 类 (CNSA 2.0) 标准,请选择 'ML-DSA-87'。

算法名称 安全等级和应用场景 公钥 ('raw-public') 签名大小
'ML-DSA-44' NIST 类别 2(约 128 位安全性);密钥和签名最小 1312 字节 2420 字节
'ML-DSA-65' NIST 类别 3(约 192 位安全性);推荐的默认值 1952 字节 3309 字节
'ML-DSA-87' NIST 类别 5(约 256 位安全性);最高安全裕度 2592 字节 4627 字节
  • 传递 'ML-DSA-44'、'ML-DSA-65' 或 'ML-DSA-87' 作为算法名称(标准化之前的名称,例如 'Dilithium' 或 'CRYSTALS-Dilithium' 会抛出 NotSupportedError)。
  • ML-DSA 和 'Ed25519' 都不接受 hash 参数。
  • 使用 generateKey() 生成密钥对时,传递 ['sign', 'verify']:
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. 导出和导入密钥

'ML-DSA-44'、'ML-DSA-65' 和 'ML-DSA-87' 拒绝 importKey() 和 exportKey() 中采用旧版 'raw' 格式,并返回 NotSupportedError。始终使用显式格式导出或导入密钥:

  • 'raw-public':公钥字节(1312、1952 或 2592 字节)。导入公钥时,请将 ['verify'] 作为密钥用途传递。
  • 'raw-seed':所有三种 ML-DSA 算法的 32 字节私钥种子。'raw' 和 'raw-private' 都会在 ML-DSA 上抛出 NotSupportedError - 使用 'raw-seed'、'pkcs8'(54 字节)或 'jwk' (kty: 'AKP') 存储或传输私钥。导入私钥时,请将 ['sign'] 作为密钥用途传递(将 ['sign', 'verify'] 传递给 importKey() 会抛出 SyntaxError)。
  • 'spki'、'pkcs8' 和 'jwk':所有三种 ML-DSA 算法均受支持。

始终使用 'raw-public' 导出验证公钥,以便将公钥字节序列化或与验证者共享:

// Export the public key bytes on the signer
const publicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  signerKeyPair.publicKey,
);

// Import the public key bytes on the verifier (usage must be ['verify'] only)
const verifierPublicKey = await crypto.subtle.importKey(
  'raw-public',
  publicKeyBytes,
  'ML-DSA-65',
  true,
  ['verify'],
);

如果您仅持有私有签名 CryptoKey(即使有 extractable: false),并且需要其对应的公共 CryptoKey,请使用 ['verify'] 调用 crypto.subtle.getPublicKey():

const publicKey = await crypto.subtle.getPublicKey(
  signerKeyPair.privateKey,
  ['verify'],
);

3. 对载荷进行签名和验证

使用私钥调用 crypto.subtle.sign() 以生成签名,并使用公钥调用 crypto.subtle.verify() 以检查签名:

const payloadBytes = new TextEncoder().encode(
  '{"artifact":"release-v2.4.0.tar.gz","sha256":"9f86d08..."}',
);

const signatureBuffer = await crypto.subtle.sign(
  'ML-DSA-65',
  signerKeyPair.privateKey,
  payloadBytes,
);

const isValid = await crypto.subtle.verify(
  'ML-DSA-65',
  verifierPublicKey,
  signatureBuffer,
  payloadBytes,
);

使用 ContextParams 进行网域分离

为防止针对某一用途创建的签名在共享同一密钥对的应用或协议的其他部分中重放,请将签名绑定到网域分离 context。

请勿传递裸算法名称字符串,而是将 ContextParams 字典 ({ name, context }) 传递给 sign() 和 verify(),其中 context 是 BufferSource(Uint8Array 或 ArrayBuffer):

const contextBytes = new TextEncoder().encode('release-manifest-v1');

const signatureBuffer = await crypto.subtle.sign(
  {
    name: 'ML-DSA-65',
    context: contextBytes,
  },
  signerKeyPair.privateKey,
  payloadBytes,
);

const isValid = await crypto.subtle.verify(
  {
    name: 'ML-DSA-65',
    context: contextBytes,
  },
  verifierPublicKey,
  signatureBuffer,
  payloadBytes,
);
  • 255 字节限制:context 接受 0 到 255 字节(省略 context 相当于传递空 new Uint8Array(0))。传递超过 255 字节会被拒绝,并返回 OperationError。
  • 需要完全匹配:仅当验证者传递与 crypto.subtle.sign() 期间使用的完全相同的 context 字节时,crypto.subtle.verify() 才会返回 true。

后备策略

如果您的 Baseline 目标尚不支持 SubtleCrypto.supports() 或 ML-DSA,请使用 static SubtleCrypto.supports() 方法检测支持情况,并回退到传统的 'Ed25519'(或在针对不支持 'Ed25519' 的浏览器时,回退到 'ECDSA' 以及 'P-256' 和 'SHA-256'):

  • 在 SubtleCrypto 构造函数(而非 crypto.subtle)上调用 SubtleCrypto.supports():globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') 是一个返回布尔值的同步静态方法(crypto.subtle.supports 为 undefined)。探测 'sign'、'verify'、'generateKey'、'importKey'、'exportKey' 或 'getPublicKey'(或传递 ContextParams 字典,例如 SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context }))。
  • 后备方案中的密钥格式 ('raw'):虽然支持现代 WebCrypto 的浏览器也接受 'Ed25519' 和 'ECDSA' 上的 'raw-public',但在针对旧版浏览器的后备路径中使用 'raw'。
  • 回退中的网域分离:传统 WebCrypto 算法('Ed25519' 和 'ECDSA')不会从算法字典中读取 context 属性(WebIDL 会静默忽略 Algorithm 和 EcdsaParams 上的未知字典属性)。在回退路径中,将长度前缀 context 标头附加到载荷,以便网域分离在两个套件中保持一致。
const ML_DSA_PUBLIC_KEY_ALGORITHMS = {
  1312: 'ML-DSA-44',
  1952: 'ML-DSA-65',
  2592: 'ML-DSA-87',
};

export function supportsPostQuantumSignatures() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('sign', 'ML-DSA-65')
  );
}

export async function generateSigningKeyPair() {
  if (supportsPostQuantumSignatures()) {
    const keyPair = await crypto.subtle.generateKey('ML-DSA-65', false, ['sign', 'verify']);
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return {
      suite: 'ML-DSA-65',
      keyFormat: 'raw-public',
      keyPair,
      publicKeyBytes,
    };
  }

  // Classical fallback: Ed25519
  const keyPair = await crypto.subtle.generateKey('Ed25519', false, ['sign', 'verify']);
  const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey));
  return {
    suite: 'Ed25519',
    keyFormat: 'raw',
    keyPair,
    publicKeyBytes,
  };
}

function frameFallbackInput(payloadBytes, contextBytes) {
  if (contextBytes.byteLength > 255) {
    throw new DOMException('Context must not exceed 255 bytes', 'OperationError');
  }
  const framed = new Uint8Array(1 + contextBytes.byteLength + payloadBytes.byteLength);
  framed[0] = contextBytes.byteLength;
  framed.set(contextBytes, 1);
  framed.set(payloadBytes, 1 + contextBytes.byteLength);
  return framed;
}

export async function signPayload(
  privateKey,
  payloadBytes,
  contextBytes = new Uint8Array(0),
) {
  if (privateKey.algorithm.name.startsWith('ML-DSA')) {
    const sig = await crypto.subtle.sign(
      { name: privateKey.algorithm.name, context: contextBytes },
      privateKey,
      payloadBytes,
    );
    return new Uint8Array(sig);
  }

  const framed = frameFallbackInput(payloadBytes, contextBytes);
  const sig = await crypto.subtle.sign('Ed25519', privateKey, framed);
  return new Uint8Array(sig);
}

export async function verifyPayload(
  publicKeyOrBytes,
  signatureBytes,
  payloadBytes,
  contextBytes = new Uint8Array(0),
) {
  const publicKey =
    publicKeyOrBytes instanceof CryptoKey
      ? publicKeyOrBytes
      : await (() => {
          const bytes =
            publicKeyOrBytes instanceof Uint8Array
              ? publicKeyOrBytes
              : new Uint8Array(publicKeyOrBytes);
          const mlDsaName = ML_DSA_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return mlDsaName
            ? crypto.subtle.importKey('raw-public', bytes, mlDsaName, true, ['verify'])
            : crypto.subtle.importKey('raw', bytes, 'Ed25519', true, ['verify']);
        })();

  if (publicKey.algorithm.name.startsWith('ML-DSA')) {
    return crypto.subtle.verify(
      { name: publicKey.algorithm.name, context: contextBytes },
      publicKey,
      signatureBytes,
      payloadBytes,
    );
  }

  const framed = frameFallbackInput(payloadBytes, contextBytes);
  return crypto.subtle.verify('Ed25519', publicKey, signatureBytes, framed);
}