使用 WebCrypto 簽署及驗證資料

網頁功能: 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。

備用策略

如果基準目標尚未支援 SubtleCrypto.supports() 或 ML-DSA,請使用靜態 SubtleCrypto.supports() 方法偵測支援,並改用傳統 'Ed25519' (如果目標瀏覽器不支援 'Ed25519',則改用 'ECDSA' 搭配 'P-256' 和 'SHA-256'):

  • 在 SubtleCrypto 建構函式上呼叫 SubtleCrypto.supports(),而不是在 crypto.subtle 上呼叫: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);
}