current commit
261025.12 · test,benchmark: drop OpenSSL 1.x-only coverage
'use strict';

const common = require('../common');

if (!common.hasCrypto)
  common.skip('missing crypto');

const { hasOpenSSL, hasFIPS, isBoringSSL } = require('../common/crypto');

const assert = require('assert');
const { getFips } = require('crypto');
const { subtle } = globalThis.crypto;
const fips3 = hasFIPS(3);
const fips35 = hasFIPS(3, 5);

const kWrappingData = {
  'RSA-OAEP': {
    generate: {
      modulusLength: 4096,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: 'SHA-256',
    },
    wrap: { label: new Uint8Array(8) },
    pair: true
  },
  'AES-CTR': {
    generate: { length: 128 },
    wrap: { counter: new Uint8Array(16), length: 64 },
    pair: false
  },
  'AES-CBC': {
    generate: { length: 128 },
    wrap: { iv: new Uint8Array(16) },
    pair: false
  },
  'AES-GCM': {
    generate: { length: 128 },
    wrap: {
      iv: new Uint8Array(16),
      additionalData: new Uint8Array(16),
      tagLength: fips3 ? 128 : 64
    },
    pair: false
  },
  'AES-KW': {
    generate: { length: 128 },
    wrap: { },
    pair: false
  },
  'ChaCha20-Poly1305': {
    wrap: {
      iv: new Uint8Array(12),
      additionalData: new Uint8Array(16),
      tagLength: 128
    },
    pair: false
  }
};

if (fips3)
  delete kWrappingData['ChaCha20-Poly1305'];

if (!isBoringSSL && !fips3) {
  kWrappingData['AES-OCB'] = {
    generate: { length: 128 },
    wrap: {
      iv: new Uint8Array(15),
      additionalData: new Uint8Array(16),
      tagLength: 128
    },
    pair: false
  };
}

function generateWrappingKeys() {
  return Promise.all(Object.keys(kWrappingData).map(async (name) => {
    const keys = await subtle.generateKey(
      { name, ...kWrappingData[name].generate },
      true,
      ['wrapKey', 'unwrapKey']);
    if (kWrappingData[name].pair) {
      kWrappingData[name].wrappingKey = keys.publicKey;
      kWrappingData[name].unwrappingKey = keys.privateKey;
    } else {
      kWrappingData[name].wrappingKey = keys;
      kWrappingData[name].unwrappingKey = keys;
    }
  }));
}

async function generateKeysToWrap() {
  const parameters = [
    {
      algorithm: {
        name: 'RSASSA-PKCS1-v1_5',
        modulusLength: getFips() === 1 ? 2048 : 1024,
        publicExponent: new Uint8Array([1, 0, 1]),
        hash: 'SHA-256'
      },
      privateUsages: ['sign'],
      publicUsages: ['verify'],
      pair: true,
    },
    {
      algorithm: {
        name: 'RSA-PSS',
        modulusLength: getFips() === 1 ? 2048 : 1024,
        publicExponent: new Uint8Array([1, 0, 1]),
        hash: 'SHA-256'
      },
      privateUsages: ['sign'],
      publicUsages: ['verify'],
      pair: true,
    },
    {
      algorithm: {
        name: 'RSA-OAEP',
        modulusLength: getFips() === 1 ? 2048 : 1024,
        publicExponent: new Uint8Array([1, 0, 1]),
        hash: 'SHA-256'
      },
      privateUsages: ['decrypt'],
      publicUsages: ['encrypt'],
      pair: true,
    },
    {
      algorithm: {
        name: 'ECDSA',
        namedCurve: 'P-384'
      },
      privateUsages: ['sign'],
      publicUsages: ['verify'],
      pair: true,
    },
    {
      algorithm: {
        name: 'ECDH',
        namedCurve: 'P-384'
      },
      privateUsages: ['deriveBits'],
      publicUsages: [],
      pair: true,
    },
    {
      algorithm: {
        name: 'Ed25519',
      },
      privateUsages: ['sign'],
      publicUsages: ['verify'],
      pair: true,
    },
    {
      algorithm: {
        name: 'X25519',
      },
      privateUsages: ['deriveBits'],
      publicUsages: [],
      pair: true,
    },
    {
      algorithm: {
        name: 'AES-CTR',
        length: 128
      },
      usages: ['encrypt', 'decrypt'],
      pair: false,
    },
    {
      algorithm: {
        name: 'AES-CBC',
        length: 128
      },
      usages: ['encrypt', 'decrypt'],
      pair: false,
    },
    {
      algorithm: {
        name: 'AES-GCM', length: 128
      },
      usages: ['encrypt', 'decrypt'],
      pair: false,
    },
    {
      algorithm: {
        name: 'HMAC',
        length: 128,
        hash: 'SHA-256'
      },
      usages: ['sign', 'verify'],
      pair: false,
    },
    {
      algorithm: {
        name: 'AES-KW',
        length: 128
      },
      usages: ['wrapKey', 'unwrapKey'],
      pair: false,
    },
    {
      algorithm: {
        name: 'ChaCha20-Poly1305'
      },
      usages: ['encrypt', 'decrypt'],
      pair: false,
    },
  ];

  if (hasOpenSSL(3, 5) || isBoringSSL) {
    for (const name of ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']) {
      parameters.push({
        algorithm: { name },
        privateUsages: ['sign'],
        publicUsages: ['verify'],
        pair: true,
      });
    }
  }

  if (!isBoringSSL) {
    parameters.push(
      {
        algorithm: {
          name: 'Ed448',
        },
        privateUsages: ['sign'],
        publicUsages: ['verify'],
        pair: true,
      },
      {
        algorithm: {
          name: 'X448',
        },
        privateUsages: ['deriveBits'],
        publicUsages: [],
        pair: true,
      },
    );
  } else {
    common.printSkipMessage('Skipping unsupported Curve test cases');
  }

  if (fips3) {
    const unsupported = new Set([
      'ChaCha20-Poly1305',
    ]);
    if (fips35) {
      unsupported.add('X25519');
      unsupported.add('X448');
    }
    for (let i = parameters.length - 1; i >= 0; --i) {
      if (unsupported.has(parameters[i].algorithm.name))
        parameters.splice(i, 1);
    }
  }

  const allkeys = await Promise.all(parameters.map(async (params) => {
    const usages = 'usages' in params ?
      params.usages :
      params.publicUsages.concat(params.privateUsages);

    const keys = await subtle.generateKey(params.algorithm, true, usages);

    if (params.pair) {
      return [
        {
          algorithm: params.algorithm,
          usages: params.publicUsages,
          key: keys.publicKey,
        },
        {
          algorithm: params.algorithm,
          usages: params.privateUsages,
          key: keys.privateKey,
        },
      ];
    }

    return [{
      algorithm: params.algorithm,
      usages: params.usages,
      key: keys,
    }];
  }));

  return allkeys.flat();
}

function getFormats(key) {
  switch (key.type) {
    case 'secret': {
      if (key.algorithm.name === 'ChaCha20-Poly1305') return ['raw-secret', 'jwk'];
      return ['raw-secret', 'raw', 'jwk'];
    };
    case 'public': {
      switch (key.algorithm.name.slice(0, 2)) {
        case 'EC': // ECDSA, ECDH
          return ['spki', 'jwk', 'raw', 'raw-public'];
        case 'ML': // ML-DSA
          return ['jwk', 'raw-public'];
        default:
          return ['spki', 'jwk'];
      }
    }
    case 'private': {
      switch (key.algorithm.name.slice(0, 2)) {
        case 'ML': // ML-DSA
          return ['jwk', 'raw-seed'];
        default:
          return ['pkcs8', 'jwk'];
      }
    }
  }
}

// If the wrapping algorithm is AES-KW, the exported key
// material length must be a multiple of 8.
// If the wrapping algorithm is RSA-OAEP, the exported key
// material maximum length is a factor of the modulusLength
//
// As per the NOTE in step 13 https://w3c.github.io/webcrypto/#SubtleCrypto-method-wrapKey
// we're padding AES-KW wrapped JWK to make sure it is always a multiple of 8 bytes
// in length
async function wrappingIsPossible(name, exported) {
  if ('byteLength' in exported) {
    switch (name) {
      case 'AES-KW':
        return exported.byteLength % 8 === 0;
      case 'RSA-OAEP':
        return exported.byteLength <= 446;
    }
  } else if ('kty' in exported && name === 'RSA-OAEP') {
    return JSON.stringify(exported).length <= 478;
  }
  return true;
}

async function testWrap(wrappingKey, unwrappingKey, key, wrap, format) {
  const exported = await subtle.exportKey(format, key.key);
  if (!(await wrappingIsPossible(wrappingKey.algorithm.name, exported)))
    return;

  const wrapped =
    await subtle.wrapKey(
      format,
      key.key,
      wrappingKey,
      { name: wrappingKey.algorithm.name, ...wrap });
  const unwrapped =
    await subtle.unwrapKey(
      format,
      wrapped,
      unwrappingKey,
      { name: wrappingKey.algorithm.name, ...wrap },
      key.algorithm,
      true,
      key.usages);
  assert(unwrapped.extractable);

  const exportedAgain = await subtle.exportKey(format, unwrapped);
  assert.deepStrictEqual(exported, exportedAgain);
}

function testWrapping(name, keys) {
  const variations = [];

  const {
    wrappingKey,
    unwrappingKey,
    wrap
  } = kWrappingData[name];

  keys.forEach((key) => {
    getFormats(key.key).forEach((format) => {
      variations.push(testWrap(wrappingKey, unwrappingKey, key, wrap, format));
    });
  });

  return variations;
}

(async function() {
  if (fips3) {
    await assert.rejects(
      subtle.generateKey(
        { name: 'ChaCha20-Poly1305' }, true, ['wrapKey']),
      { name: 'NotSupportedError' });

    if (fips35) {
      for (const name of ['X25519', 'X448']) {
        await assert.rejects(
          subtle.generateKey({ name }, true, ['deriveBits']),
          { name: 'NotSupportedError', message: 'Unrecognized algorithm name' });
      }
    }

    await assert.rejects(
      subtle.generateKey({ name: 'AES-OCB', length: 128 }, true, ['wrapKey']),
      { name: 'NotSupportedError', message: 'Unrecognized algorithm name' });
    const key = await subtle.generateKey(
      { name: 'HMAC', hash: 'SHA-256', length: 256 },
      true,
      ['sign']);
    await assert.rejects(
      subtle.wrapKey(
        'raw',
        key,
        key,
        { name: 'AES-OCB', iv: new Uint8Array(15), tagLength: 128 }),
      { name: 'NotSupportedError', message: 'Unrecognized algorithm name' });
  }

  await generateWrappingKeys();
  const keys = await generateKeysToWrap();
  const variations = [];
  Object.keys(kWrappingData).forEach((name) => {
    variations.push(...testWrapping(name, keys));
  });
  await Promise.all(variations);
})().then(common.mustCall());

async function testNonByteLengthWrapUnwrap({
  key,
  formats,
  rawFormat,
  explicitAlgorithm,
  implicitAlgorithm,
}) {
  const wrappingKey = await subtle.generateKey(
    { name: 'AES-GCM', length: 128 },
    true,
    ['wrapKey', 'unwrapKey']);
  const expectedRaw = new Uint8Array(await subtle.exportKey(rawFormat, key));

  for (const [i, format] of formats.entries()) {
    const wrapAlgorithm = {
      name: 'AES-GCM',
      iv: new Uint8Array(12).fill(i),
    };
    const wrapped = await subtle.wrapKey(format, key, wrappingKey, wrapAlgorithm);

    // The serialized key material carries bytes, not the requested bit length.
    const explicit = await subtle.unwrapKey(
      format,
      wrapped,
      wrappingKey,
      wrapAlgorithm,
      explicitAlgorithm,
      true,
      ['sign', 'verify']);
    assert.strictEqual(explicit.algorithm.length, 9);
    assert.deepStrictEqual(
      new Uint8Array(await subtle.exportKey(rawFormat, explicit)),
      expectedRaw);

    const implicit = await subtle.unwrapKey(
      format,
      wrapped,
      wrappingKey,
      wrapAlgorithm,
      implicitAlgorithm,
      true,
      ['sign', 'verify']);
    assert.strictEqual(implicit.algorithm.length, expectedRaw.byteLength * 8);
    assert.deepStrictEqual(
      new Uint8Array(await subtle.exportKey(rawFormat, implicit)),
      expectedRaw);
  }
}

(async function() {
  const hmacAlgorithm = { name: 'HMAC', hash: 'SHA-256' };
  const hmacKey = await subtle.importKey(
    'raw',
    new Uint8Array([0xff, 0xff]),
    { ...hmacAlgorithm, length: 9 },
    true,
    ['sign', 'verify']);
  await testNonByteLengthWrapUnwrap({
    key: hmacKey,
    formats: ['raw', 'jwk'],
    rawFormat: 'raw',
    explicitAlgorithm: { ...hmacAlgorithm, length: 9 },
    implicitAlgorithm: hmacAlgorithm,
  });

  if (!isBoringSSL && getFips() !== 1) {
    for (const name of ['KMAC128', 'KMAC256']) {
      const keyData = new Uint8Array(32).fill(0xff);
      const kmacKey = await subtle.importKey(
        'raw-secret', keyData, name, true, ['sign', 'verify']);
      const wrappingKey = await subtle.generateKey(
        { name: 'AES-GCM', length: 128 }, true, ['wrapKey', 'unwrapKey']);

      for (const [i, format] of ['raw-secret', 'jwk'].entries()) {
        const wrapAlgorithm = { name: 'AES-GCM', iv: new Uint8Array(12).fill(i) };
        const wrapped = await subtle.wrapKey(format, kmacKey, wrappingKey, wrapAlgorithm);
        await assert.rejects(
          subtle.unwrapKey(
            format, wrapped, wrappingKey, wrapAlgorithm,
            { name, length: 255 }, true, ['sign', 'verify']),
          { name: 'NotSupportedError', message: 'Invalid key length' });

        const unwrapped = await subtle.unwrapKey(
          format, wrapped, wrappingKey, wrapAlgorithm,
          { name, length: 256 }, true, ['sign', 'verify']);
        assert.strictEqual(unwrapped.algorithm.length, 256);
        assert.deepStrictEqual(
          new Uint8Array(await subtle.exportKey('raw-secret', unwrapped)), keyData);
      }
    }
  }
})().then(common.mustCall());

// Test that wrapKey/unwrapKey validate the wrapping/unwrapping key's
// algorithm and usage before proceeding.
// Spec: https://w3c.github.io/webcrypto/#SubtleCrypto-method-wrapKey
// Steps 9-10 (wrapping key checks) must precede step 13 (export operation).
(async function() {
  const hmacKey = await subtle.generateKey(
    { name: 'HMAC', hash: 'SHA-256' },
    true,
    ['sign', 'verify'],
  );

  const ecKey = await subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );

  // Wrong algorithm: wrapping key is HMAC but algorithm says AES-GCM.
  // Even though exporting ecKey.privateKey as 'spki' would also fail
  // (wrong key type for spki), the wrapping key check must come first.
  await assert.rejects(
    subtle.wrapKey('spki', ecKey.privateKey, hmacKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }), {
      name: 'InvalidAccessError',
      message: 'Key algorithm mismatch',
    });

  // Missing wrapKey usage: aesKey only has encrypt/decrypt, not wrapKey.
  // Even though exporting ecKey.privateKey as 'spki' would also fail,
  // the usage check must come first.
  const aesKey = await subtle.generateKey(
    { name: 'AES-GCM', length: 128 },
    true,
    ['encrypt', 'decrypt'],
  );

  await assert.rejects(
    subtle.wrapKey('spki', ecKey.privateKey, aesKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }), {
      name: 'InvalidAccessError',
      message: 'Unable to use this key to wrapKey',
    });

  // Correct wrapping key algorithm and usage results in the expected
  // export operation error (not the wrapping key validation error).
  const wrapKey = await subtle.generateKey(
    { name: 'AES-GCM', length: 128 },
    true,
    ['wrapKey'],
  );

  await assert.rejects(
    subtle.wrapKey('spki', ecKey.privateKey, wrapKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }), {
      message: 'Key must be a public key',
      name: 'InvalidAccessError',
    });

  // Symmetric case: exporting a public key as 'pkcs8' must also fail with
  // InvalidAccessError, not the generic NotSupportedError.
  await assert.rejects(
    subtle.wrapKey('pkcs8', ecKey.publicKey, wrapKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }), {
      message: 'Key must be a private key',
      name: 'InvalidAccessError',
    });

  // Exporting a private key as 'raw' must also fail with InvalidAccessError.
  await assert.rejects(
    subtle.wrapKey('raw', ecKey.privateKey, wrapKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }), {
      message: 'Key must be a public key',
      name: 'InvalidAccessError',
    });

  // --- unwrapKey validation tests ---

  const ciphertext = new Uint8Array(32); // Dummy ciphertext

  // Wrong algorithm: unwrapping key is HMAC but algorithm says AES-GCM.
  await assert.rejects(
    subtle.unwrapKey('raw', ciphertext, hmacKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }, { name: 'AES-GCM', length: 128 }, true, ['encrypt']), {
      name: 'InvalidAccessError',
      message: 'Key algorithm mismatch',
    });

  // Missing unwrapKey usage: aesKey only has encrypt/decrypt, not unwrapKey.
  await assert.rejects(
    subtle.unwrapKey('raw', ciphertext, aesKey, {
      name: 'AES-GCM',
      iv: new Uint8Array(12),
    }, { name: 'AES-GCM', length: 128 }, true, ['encrypt']), {
      name: 'InvalidAccessError',
      message: 'Unable to use this key to unwrapKey',
    });
})().then(common.mustCall());