node/test/parallel/test-webcrypto-deduplicate-usages.js
Filip Skokan 550f195277 crypto: wire ML-DSA and ML-KEM for use when using BoringSSL
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/63255
Refs: https://github.com/electron/electron/issues/36256
Refs: https://github.com/electron/electron/issues/41720
Refs: https://github.com/electron/electron/pull/51127
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
2026-05-17 18:18:14 +00:00

536 lines
20 KiB
JavaScript

'use strict';
// Regression test for https://github.com/nodejs/node/issues/62899
// SubtleCrypto.generateKey(), SubtleCrypto.importKey(), and
// KeyObject.prototype.toCryptoKey() should produce CryptoKey values whose
// `usages` have been de-duplicated and returned in a canonical order.
// The same applies to `key_ops` on JWK exports of extractable keys.
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const { createSecretKey } = require('crypto');
const { hasOpenSSL } = require('../common/crypto');
const { subtle } = globalThis.crypto;
function assertSameSet(actual, expected, msg) {
if (msg === undefined) {
assert.deepStrictEqual(actual, expected);
} else {
assert.deepStrictEqual(actual, expected, msg);
}
}
{
const tests = [];
// Symmetric keys (single CryptoKey result). Inputs are deliberately in
// non-canonical order so the test exercises the canonical re-ordering.
const symmetric = [
{ algorithm: { name: 'HMAC', hash: 'SHA-256' },
usages: ['verify', 'sign', 'verify', 'sign'],
expected: ['sign', 'verify'] },
{ algorithm: { name: 'AES-CTR', length: 128 },
usages: ['wrapKey', 'decrypt', 'encrypt', 'unwrapKey', 'wrapKey', 'encrypt'],
expected: ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] },
{ algorithm: { name: 'AES-CBC', length: 128 },
usages: ['encrypt', 'encrypt'],
expected: ['encrypt'] },
{ algorithm: { name: 'AES-GCM', length: 128 },
usages: ['decrypt', 'encrypt', 'decrypt'],
expected: ['encrypt', 'decrypt'] },
{ algorithm: { name: 'AES-KW', length: 128 },
usages: ['wrapKey', 'unwrapKey', 'wrapKey', 'unwrapKey'],
expected: ['wrapKey', 'unwrapKey'] },
{ algorithm: { name: 'ChaCha20-Poly1305' },
usages: ['wrapKey', 'decrypt', 'encrypt', 'unwrapKey', 'wrapKey', 'encrypt'],
expected: ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] },
];
if (hasOpenSSL(3)) {
symmetric.push({
algorithm: { name: 'AES-OCB', length: 128 },
usages: ['decrypt', 'encrypt', 'decrypt', 'encrypt'],
expected: ['encrypt', 'decrypt'],
});
symmetric.push({
algorithm: { name: 'KMAC128', length: 128 },
usages: ['verify', 'sign', 'verify', 'sign'],
expected: ['sign', 'verify'],
});
} else {
common.printSkipMessage('AES-OCB and KMAC require OpenSSL >= 3');
}
for (const { algorithm, usages, expected } of symmetric) {
tests.push((async () => {
const key = await subtle.generateKey(algorithm, true, usages);
assertSameSet(key.usages, expected,
`generateKey ${algorithm.name}`);
assert.strictEqual(key.usages.length, expected.length,
`generateKey ${algorithm.name} usage count`);
})());
}
// Asymmetric keys (CryptoKeyPair result). Duplicates across the input
// must not produce duplicates on either the public or private key.
const asymmetric = [
{ algorithm: { name: 'RSA-OAEP', modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
usages: ['wrapKey', 'unwrapKey', 'decrypt', 'encrypt',
'unwrapKey', 'wrapKey', 'decrypt', 'encrypt'],
publicExpected: ['encrypt', 'wrapKey'],
privateExpected: ['decrypt', 'unwrapKey'] },
{ algorithm: { name: 'RSA-PSS', modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
usages: ['verify', 'sign', 'verify', 'sign'],
publicExpected: ['verify'],
privateExpected: ['sign'] },
{ algorithm: { name: 'ECDSA', namedCurve: 'P-256' },
usages: ['verify', 'sign', 'verify', 'sign', 'verify'],
publicExpected: ['verify'],
privateExpected: ['sign'] },
{ algorithm: { name: 'ECDH', namedCurve: 'P-256' },
usages: ['deriveBits', 'deriveKey', 'deriveBits', 'deriveKey'],
publicExpected: [],
privateExpected: ['deriveKey', 'deriveBits'] },
{ algorithm: { name: 'Ed25519' },
usages: ['verify', 'sign', 'verify', 'sign'],
publicExpected: ['verify'],
privateExpected: ['sign'] },
{ algorithm: { name: 'X25519' },
usages: ['deriveBits', 'deriveKey', 'deriveBits', 'deriveKey'],
publicExpected: [],
privateExpected: ['deriveKey', 'deriveBits'] },
];
if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) {
asymmetric.push({
algorithm: { name: 'ML-DSA-65' },
usages: ['verify', 'sign', 'verify', 'sign'],
publicExpected: ['verify'],
privateExpected: ['sign'],
});
asymmetric.push({
algorithm: { name: 'ML-KEM-768' },
usages: ['decapsulateBits', 'encapsulateBits', 'decapsulateKey',
'encapsulateKey', 'decapsulateBits', 'encapsulateBits'],
publicExpected: ['encapsulateKey', 'encapsulateBits'],
privateExpected: ['decapsulateKey', 'decapsulateBits'],
});
} else {
common.printSkipMessage('ML-DSA and ML-KEM require OpenSSL >= 3.5 or BoringSSL');
}
for (const { algorithm, usages, publicExpected, privateExpected } of asymmetric) {
tests.push((async () => {
const { publicKey, privateKey } =
await subtle.generateKey(algorithm, true, usages);
assertSameSet(publicKey.usages, publicExpected,
`generateKey ${algorithm.name} publicKey`);
assert.strictEqual(publicKey.usages.length, publicExpected.length);
assertSameSet(privateKey.usages, privateExpected,
`generateKey ${algorithm.name} privateKey`);
assert.strictEqual(privateKey.usages.length, privateExpected.length);
})());
}
Promise.all(tests).then(common.mustCall());
}
{
const tests = [];
// Symmetric raw imports.
const rawSymmetric = [
{ algorithm: { name: 'AES-CBC' }, keyData: new Uint8Array(16),
usages: ['decrypt', 'encrypt', 'decrypt', 'encrypt'],
expected: ['encrypt', 'decrypt'] },
{ algorithm: { name: 'AES-CTR' }, keyData: new Uint8Array(16),
usages: ['wrapKey', 'encrypt', 'wrapKey', 'encrypt'],
expected: ['encrypt', 'wrapKey'] },
{ algorithm: { name: 'AES-GCM' }, keyData: new Uint8Array(16),
usages: ['decrypt', 'encrypt', 'decrypt'],
expected: ['encrypt', 'decrypt'] },
{ algorithm: { name: 'HMAC', hash: 'SHA-256' }, keyData: new Uint8Array(32),
usages: ['verify', 'sign', 'verify', 'sign'],
expected: ['sign', 'verify'] },
{ algorithm: { name: 'AES-KW' }, keyData: new Uint8Array(16),
usages: ['wrapKey', 'unwrapKey', 'wrapKey'],
expected: ['wrapKey', 'unwrapKey'] },
];
if (hasOpenSSL(3)) {
// KMAC does not support `raw` format, only `raw-secret` and `jwk`.
tests.push((async () => {
const key = await subtle.importKey(
'raw-secret', new Uint8Array(16), { name: 'KMAC128' }, true,
['verify', 'sign', 'verify', 'sign']);
assertSameSet(key.usages, ['sign', 'verify'],
'importKey raw-secret KMAC128');
assert.strictEqual(key.usages.length, 2);
})());
tests.push((async () => {
const jwk = {
kty: 'oct',
k: Buffer.from(new Uint8Array(16)).toString('base64url'),
alg: 'K128',
};
const key = await subtle.importKey(
'jwk', jwk, { name: 'KMAC128' }, true,
['verify', 'sign', 'verify', 'sign']);
assertSameSet(key.usages, ['sign', 'verify'],
'importKey jwk KMAC128');
assert.strictEqual(key.usages.length, 2);
})());
} else {
common.printSkipMessage('AES-OCB and KMAC require OpenSSL >= 3');
}
for (const { algorithm, keyData, usages, expected } of rawSymmetric) {
tests.push((async () => {
const key = await subtle.importKey('raw', keyData, algorithm, true, usages);
assertSameSet(key.usages, expected,
`importKey raw ${algorithm.name}`);
assert.strictEqual(key.usages.length, expected.length);
})());
}
// Generic secret keys (HKDF, PBKDF2) - importGenericSecretKey path.
// These are not extractable.
for (const name of ['HKDF', 'PBKDF2']) {
tests.push((async () => {
const key = await subtle.importKey(
'raw',
new Uint8Array(16),
name,
false,
['deriveBits', 'deriveKey', 'deriveBits', 'deriveKey']);
assertSameSet(key.usages, ['deriveKey', 'deriveBits'],
`importKey raw ${name}`);
assert.strictEqual(key.usages.length, 2);
})());
}
// Argon2 - also via importGenericSecretKey, deriveBits-only.
// Argon2 only supports raw-secret import.
if (hasOpenSSL(3, 2)) {
tests.push((async () => {
const key = await subtle.importKey(
'raw-secret',
new Uint8Array(16),
'Argon2id',
false,
['deriveBits', 'deriveBits']);
assertSameSet(key.usages, ['deriveBits'],
'importKey raw-secret Argon2id');
assert.strictEqual(key.usages.length, 1);
})());
} else {
common.printSkipMessage('Argon2 requires OpenSSL >= 3.2');
}
// JWK symmetric import.
tests.push((async () => {
const jwk = {
kty: 'oct',
k: 'AAAAAAAAAAAAAAAAAAAAAA',
alg: 'A128CBC',
};
const key = await subtle.importKey('jwk', jwk, { name: 'AES-CBC' }, true,
['decrypt', 'encrypt', 'decrypt']);
assertSameSet(key.usages, ['encrypt', 'decrypt'],
'importKey jwk AES-CBC');
assert.strictEqual(key.usages.length, 2);
})());
// Asymmetric import via JWK - RSA, ECDSA, Ed25519.
tests.push((async () => {
// Generate, export, re-import with duplicate usages.
const { privateKey } = await subtle.generateKey(
{ name: 'RSA-PSS', modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', privateKey);
const imported = await subtle.importKey(
'jwk', jwk,
{ name: 'RSA-PSS', hash: 'SHA-256' },
true,
['sign', 'sign', 'sign']);
assert.deepStrictEqual(imported.usages, ['sign']);
})());
tests.push((async () => {
const { privateKey } = await subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', privateKey);
const imported = await subtle.importKey(
'jwk', jwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'sign']);
assert.deepStrictEqual(imported.usages, ['sign']);
})());
tests.push((async () => {
const { privateKey } = await subtle.generateKey(
{ name: 'Ed25519' }, true, ['sign', 'verify']);
const pkcs8 = await subtle.exportKey('pkcs8', privateKey);
const imported = await subtle.importKey(
'pkcs8', pkcs8,
{ name: 'Ed25519' },
true,
['sign', 'sign', 'sign']);
assert.deepStrictEqual(imported.usages, ['sign']);
})());
if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) {
// ML-DSA JWK roundtrip.
tests.push((async () => {
const { privateKey } = await subtle.generateKey(
{ name: 'ML-DSA-65' }, true, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', privateKey);
const imported = await subtle.importKey(
'jwk', jwk, { name: 'ML-DSA-65' }, true,
['sign', 'sign', 'sign']);
assert.deepStrictEqual(imported.usages, ['sign']);
})());
// ML-KEM JWK roundtrip.
tests.push((async () => {
const { privateKey } = await subtle.generateKey(
{ name: 'ML-KEM-768' }, true,
['decapsulateKey', 'decapsulateBits']);
const jwk = await subtle.exportKey('jwk', privateKey);
const imported = await subtle.importKey(
'jwk', jwk, { name: 'ML-KEM-768' }, true,
['decapsulateBits', 'decapsulateKey',
'decapsulateBits', 'decapsulateKey']);
assert.deepStrictEqual(imported.usages,
['decapsulateKey', 'decapsulateBits']);
})());
} else {
common.printSkipMessage('ML-DSA and ML-KEM require OpenSSL >= 3.5 or BoringSSL');
}
// Spki import of RSA public key.
tests.push((async () => {
const { publicKey } = await subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true, ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']);
const spki = await subtle.exportKey('spki', publicKey);
const imported = await subtle.importKey(
'spki', spki,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['wrapKey', 'encrypt', 'wrapKey', 'encrypt']);
assertSameSet(imported.usages, ['encrypt', 'wrapKey']);
assert.strictEqual(imported.usages.length, 2);
})());
// ChaCha20-Poly1305 raw-secret import.
tests.push((async () => {
const key = await subtle.importKey(
'raw-secret',
new Uint8Array(32),
{ name: 'ChaCha20-Poly1305' },
true,
['decrypt', 'encrypt', 'decrypt', 'encrypt']);
assertSameSet(key.usages, ['encrypt', 'decrypt']);
assert.strictEqual(key.usages.length, 2);
})());
// AES-OCB raw-secret import.
if (hasOpenSSL(3)) {
tests.push((async () => {
const key = await subtle.importKey(
'raw-secret',
new Uint8Array(16),
{ name: 'AES-OCB' },
true,
['decrypt', 'encrypt', 'decrypt', 'encrypt']);
assertSameSet(key.usages, ['encrypt', 'decrypt']);
assert.strictEqual(key.usages.length, 2);
})());
} else {
common.printSkipMessage('AES-OCB requires OpenSSL >= 3');
}
Promise.all(tests).then(common.mustCall());
}
{
const tests = [];
// Symmetric: HMAC, AES-*, HKDF, PBKDF2
tests.push((async () => {
const keyObject = createSecretKey(new Uint8Array(32));
const key = keyObject.toCryptoKey(
{ name: 'HMAC', hash: 'SHA-256' },
true,
['verify', 'sign', 'verify', 'sign']);
assertSameSet(key.usages, ['sign', 'verify']);
assert.strictEqual(key.usages.length, 2);
})());
tests.push((async () => {
const keyObject = createSecretKey(new Uint8Array(16));
const key = keyObject.toCryptoKey(
{ name: 'AES-GCM' },
true,
['decrypt', 'encrypt', 'decrypt']);
assertSameSet(key.usages, ['encrypt', 'decrypt']);
assert.strictEqual(key.usages.length, 2);
})());
tests.push((async () => {
const keyObject = createSecretKey(new Uint8Array(32));
const key = keyObject.toCryptoKey(
'HKDF',
false,
['deriveBits', 'deriveKey', 'deriveBits']);
assertSameSet(key.usages, ['deriveKey', 'deriveBits']);
assert.strictEqual(key.usages.length, 2);
})());
Promise.all(tests).then(common.mustCall());
}
{
(async () => {
const key = await subtle.generateKey(
{ name: 'AES-CTR', length: 128 },
true,
['wrapKey', 'encrypt', 'decrypt', 'encrypt', 'wrapKey', 'unwrapKey']);
// Regardless of the input order, de-duplicated usages are returned in
// a canonical order.
assert.deepStrictEqual(
key.usages,
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']);
})().then(common.mustCall());
}
// Exported JWK `key_ops` must also be de-duplicated.
{
const tests = [];
const jwkVectors = [
{ algorithm: { name: 'HMAC', hash: 'SHA-256' },
usages: ['verify', 'sign', 'verify', 'sign'],
expected: ['sign', 'verify'] },
{ algorithm: { name: 'AES-CBC', length: 128 },
usages: ['decrypt', 'encrypt', 'decrypt', 'encrypt'],
expected: ['encrypt', 'decrypt'] },
{ algorithm: { name: 'AES-GCM', length: 128 },
usages: ['decrypt', 'encrypt', 'decrypt'],
expected: ['encrypt', 'decrypt'] },
{ algorithm: { name: 'AES-KW', length: 128 },
usages: ['wrapKey', 'unwrapKey', 'wrapKey', 'unwrapKey'],
expected: ['wrapKey', 'unwrapKey'] },
];
if (hasOpenSSL(3)) {
jwkVectors.push({
algorithm: { name: 'AES-OCB', length: 128 },
usages: ['decrypt', 'encrypt', 'decrypt', 'encrypt'],
expected: ['encrypt', 'decrypt'],
});
jwkVectors.push({
algorithm: { name: 'KMAC128', length: 128 },
usages: ['verify', 'sign', 'verify', 'sign'],
expected: ['sign', 'verify'],
});
} else {
common.printSkipMessage('AES-OCB and KMAC require OpenSSL >= 3');
}
for (const { algorithm, usages, expected } of jwkVectors) {
tests.push((async () => {
const key = await subtle.generateKey(algorithm, true, usages);
const jwk = await subtle.exportKey('jwk', key);
assertSameSet(jwk.key_ops, expected,
`jwk key_ops for ${algorithm.name}`);
assert.strictEqual(jwk.key_ops.length, expected.length,
`jwk key_ops length for ${algorithm.name}`);
})());
}
const jwkPairVectors = [
{ algorithm: { name: 'RSA-OAEP', modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
usages: ['wrapKey', 'unwrapKey', 'decrypt', 'encrypt',
'unwrapKey', 'wrapKey', 'decrypt', 'encrypt'],
publicExpected: ['encrypt', 'wrapKey'],
privateExpected: ['decrypt', 'unwrapKey'] },
{ algorithm: { name: 'RSA-PSS', modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
usages: ['verify', 'sign', 'verify', 'sign'],
publicExpected: ['verify'],
privateExpected: ['sign'] },
{ algorithm: { name: 'ECDSA', namedCurve: 'P-256' },
usages: ['verify', 'sign', 'verify', 'sign', 'verify'],
publicExpected: ['verify'],
privateExpected: ['sign'] },
{ algorithm: { name: 'ECDH', namedCurve: 'P-256' },
usages: ['deriveBits', 'deriveKey', 'deriveBits', 'deriveKey'],
publicExpected: undefined,
privateExpected: ['deriveKey', 'deriveBits'] },
{ algorithm: { name: 'Ed25519' },
usages: ['verify', 'sign', 'verify', 'sign'],
publicExpected: ['verify'],
privateExpected: ['sign'] },
{ algorithm: { name: 'X25519' },
usages: ['deriveBits', 'deriveKey', 'deriveBits', 'deriveKey'],
publicExpected: undefined,
privateExpected: ['deriveKey', 'deriveBits'] },
];
if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) {
jwkPairVectors.push({
algorithm: { name: 'ML-DSA-65' },
usages: ['verify', 'sign', 'verify', 'sign'],
publicExpected: ['verify'],
privateExpected: ['sign'],
});
jwkPairVectors.push({
algorithm: { name: 'ML-KEM-768' },
usages: ['decapsulateBits', 'encapsulateBits', 'decapsulateKey',
'encapsulateKey', 'decapsulateBits', 'encapsulateBits'],
publicExpected: ['encapsulateKey', 'encapsulateBits'],
privateExpected: ['decapsulateKey', 'decapsulateBits'],
});
} else {
common.printSkipMessage('ML-DSA and ML-KEM require OpenSSL >= 3.5 or BoringSSL');
}
for (const { algorithm, usages, publicExpected, privateExpected } of jwkPairVectors) {
tests.push((async () => {
const { publicKey, privateKey } =
await subtle.generateKey(algorithm, true, usages);
const publicJwk = await subtle.exportKey('jwk', publicKey);
const privateJwk = await subtle.exportKey('jwk', privateKey);
if (publicExpected === undefined) {
// Empty public-key usages result in an empty `key_ops`.
assert.deepStrictEqual(publicJwk.key_ops, [],
`jwk key_ops for ${algorithm.name} publicKey`);
} else {
assertSameSet(publicJwk.key_ops, publicExpected,
`jwk key_ops for ${algorithm.name} publicKey`);
assert.strictEqual(publicJwk.key_ops.length, publicExpected.length);
}
assertSameSet(privateJwk.key_ops, privateExpected,
`jwk key_ops for ${algorithm.name} privateKey`);
assert.strictEqual(privateJwk.key_ops.length, privateExpected.length);
})());
}
Promise.all(tests).then(common.mustCall());
}