Используйте 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);
}