Подписание и проверка данных с помощью WebCrypto

Веб-функции: Web Cryptography

Используйте Web Cryptography API (crypto.subtle), чтобы подписывать и проверять сообщения, токены или артефакты в браузере с помощью устойчивых к квантовым атакам (ML-DSA) или классических ('Ed25519', 'ECDSA') асимметричных ключей. Цифровые подписи гарантируют целостность данных и подлинность подписавшего без необходимости использования общих секретных ключей. Информацию о том, как создавать общие секретные ключи и шифровать конфиденциальные полезные нагрузки, вы найдете в статье Как шифровать данные на стороне клиента с помощью WebCrypto.

1. Выберите алгоритм и сгенерируйте пару ключей

ML-DSA (Module-Lattice-Based Digital Signature Algorithm, стандартизированный в NIST FIPS 204) – это алгоритм цифровой подписи на основе решеток, устойчивый к атакам как классических, так и квантовых компьютеров.

Используйте 'ML-DSA-65' по умолчанию для подписи общего назначения. Выберите 'ML-DSA-44', если вам нужны меньшие открытые ключи и подписи по каналам с ограниченной пропускной способностью и достаточно 128-битной защиты, или 'ML-DSA-87', если требования правил безопасности требуют соответствия NIST Category 5 (CNSA 2.0).

Название алгоритма Уровень безопасности и сценарий использования Открытый ключ ('raw-public') Размер подписи
'ML-DSA-44' Категория 2 NIST (примерно 128-битная защита); самые маленькие ключи и подписи 1312 байт 2420 байт
'ML-DSA-65' NIST Category 3 (~192-bit security); recommended default 1952 байта 3309 байт
'ML-DSA-87' Категория 5 NIST (примерно 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' отклоняют устаревший формат 'raw' в importKey() и exportKey() с NotSupportedError. Всегда экспортируйте или импортируйте ключи в явном формате:

  • 'raw-public' – байты открытого ключа (1312, 1952 или 2592 байт). При импорте открытого ключа передайте ['verify'] в качестве назначения ключа.
  • 'raw-seed' – 32-байтовый начальный закрытый ключ для всех трех алгоритмов ML-DSA. И 'raw', и 'raw-private' вызывают NotSupportedError в ML-DSA. Храните или передавайте закрытые ключи с помощью '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, вызовите crypto.subtle.getPublicKey() с ['verify']:

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.verify() возвращает true, только если проверяющий передает те же байты context, которые использовались во время crypto.subtle.sign().

Резервные стратегии

Если целевая платформа Baseline пока не поддерживает SubtleCrypto.supports() или ML-DSA, определите поддержку с помощью статического метода SubtleCrypto.supports() и используйте классический 'Ed25519' (или 'ECDSA' с 'P-256' и 'SHA-256', если целевые браузеры не поддерживают 'Ed25519'):

  • Вызовите SubtleCrypto.supports() в конструкторе SubtleCrypto, а не в 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'). Браузеры, поддерживающие Modern WebCrypto, также принимают 'raw-public' в 'Ed25519' и 'ECDSA', но в резервных путях для более старых браузеров используйте '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);
}