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 { hasFIPS, hasOpenSSL, isBoringSSL } = require('../common/crypto');
if (isBoringSSL)
  common.skip('OpenSSL provider support is required');

// Verifies that crypto.createPrivateKey() can pass a WHATWG URL (here a file:
// URI) to an OpenSSL STORE loader, and that the resulting KeyObject works for
// signing and verification.

const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const {
  createPublicKey,
  createPrivateKey,
  createVerify,
  decapsulate,
  diffieHellman,
  encapsulate,
  generateKeyPairSync,
  privateDecrypt,
  privateEncrypt,
  publicDecrypt,
  publicEncrypt,
  sign,
  verify,
} = require('crypto');
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();

const data = Buffer.from('hello store');

{
  const { privateKey, publicKey } = generateKeyPairSync('ed25519');
  const file = path.join(tmpdir.path, 'priv.pem');
  fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' }));
  const url = pathToFileURL(file);

  const pk = createPrivateKey(url);
  assert.strictEqual(pk.type, 'private');
  assert.strictEqual(pk.asymmetricKeyType, 'ed25519');

  const sig = sign(null, data, pk);
  assert.strictEqual(verify(null, data, publicKey, sig), true);

  const pkWithProperties = createPrivateKey({ key: url, properties: '' });
  assert.strictEqual(pkWithProperties.type, 'private');
  assert.strictEqual(pkWithProperties.asymmetricKeyType, 'ed25519');
  assert.strictEqual(
    verify(null, data, publicKey, sign(null, data, pkWithProperties)),
    true);

  // Passing the URL inline to sign() behaves like createPrivateKey().
  assert.strictEqual(verify(null, data, publicKey, sign(null, data, url)), true);

  assert.throws(() => createPrivateKey({ key: url, properties: 1 }), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
}

{
  const { privateKey, publicKey } = generateKeyPairSync('rsa', {
    modulusLength: 2048,
  });
  const file = path.join(tmpdir.path, 'rsa.pem');
  fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' }));
  const url = pathToFileURL(file);
  const plaintext = Buffer.from('hello rsa store');

  const ciphertext = publicEncrypt(publicKey, plaintext);
  assert.deepStrictEqual(privateDecrypt(url, ciphertext), plaintext);
  assert.deepStrictEqual(privateDecrypt({ key: url }, ciphertext), plaintext);

  const encrypted = privateEncrypt(url, plaintext);
  assert.deepStrictEqual(publicDecrypt(publicKey, encrypted), plaintext);

  const encryptedFromObject = privateEncrypt({ key: url }, plaintext);
  assert.deepStrictEqual(publicDecrypt(publicKey, encryptedFromObject),
                         plaintext);
}

{
  if (hasFIPS(3, 5)) {
    assert.throws(() => generateKeyPairSync('x25519'), {
      code: 'ERR_INVALID_ARG_VALUE',
    });
  } else {
    const alice = generateKeyPairSync('x25519');
    const bob = generateKeyPairSync('x25519');
    const file = path.join(tmpdir.path, 'x25519.pem');
    fs.writeFileSync(file, alice.privateKey.export({
      format: 'pem',
      type: 'pkcs8',
    }));
    const url = pathToFileURL(file);

    const expected = diffieHellman({
      privateKey: alice.privateKey,
      publicKey: bob.publicKey,
    });
    assert.deepStrictEqual(
      diffieHellman({ privateKey: url, publicKey: bob.publicKey }),
      expected);

    if (hasOpenSSL(3, 2)) {
      const { sharedKey, ciphertext } = encapsulate(alice.publicKey);
      assert.deepStrictEqual(decapsulate(url, ciphertext), sharedKey);
    }
  }
}

{
  // Encrypted PKCS#8 with passphrase via { key: url, passphrase }.
  const passphrase = 'correct-passphrase';
  const { privateKey, publicKey } = generateKeyPairSync('ed25519');
  const file = path.join(tmpdir.path, 'enc.pem');
  fs.writeFileSync(file, privateKey.export({
    format: 'pem', type: 'pkcs8', cipher: 'aes-256-cbc', passphrase,
  }));
  const url = pathToFileURL(file);

  const sig = sign(null, data, { key: url, passphrase: Buffer.from(passphrase) });
  assert.strictEqual(verify(null, data, publicKey, sig), true);

  assert.throws(() => createPrivateKey(url), {
    code: 'ERR_MISSING_PASSPHRASE',
  });

  assert.throws(() => createPrivateKey({ key: url, passphrase: 'wrong-passphrase' }),
                common.expectsError({
                  name: 'Error',
                  code: /^ERR_OSSL_/,
                }));
}

{
  // A URL is only accepted in private-key contexts.
  const url = pathToFileURL(path.join(tmpdir.path, 'priv.pem'));
  assert.throws(() => createPublicKey(url), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => createPublicKey({ key: url }), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => publicEncrypt(url, data), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => publicEncrypt({ key: url }, data), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => publicDecrypt(url, Buffer.alloc(0)), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => verify(null, data, url, Buffer.alloc(0)), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => verify(null, data, { key: url }, Buffer.alloc(0)), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  const verifier = createVerify('sha256');
  verifier.update(data);
  assert.throws(() => verifier.verify(url, Buffer.alloc(0)), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => encapsulate(url), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => encapsulate({ key: url }), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(() => diffieHellman({
    privateKey: createPrivateKey(url),
    publicKey: url,
  }), {
    code: 'ERR_INVALID_ARG_TYPE',
  });

  assert.throws(() => createPrivateKey(1), {
    code: 'ERR_INVALID_ARG_TYPE',
    message: /URL/,
  });
}

{
  // A readable URI that holds no private key is distinguishable from a URI
  // that could not be opened at all.
  const file = path.join(tmpdir.path, 'nope.pem');
  fs.writeFileSync(file, 'not a key');
  assert.throws(() => createPrivateKey(pathToFileURL(file)), {
    code: 'ERR_CRYPTO_OPERATION_FAILED',
  });

  // OpenSSL has no reason string for system library errors, so error.code
  // cannot be derived for these. Which entry becomes the message is not
  // portable either: some builds leave a generic STORE `unsupported` on top of
  // the error the loader itself raised. The reason is reported either way.
  assert.throws(
    () => createPrivateKey(pathToFileURL(path.join(tmpdir.path, 'missing.pem'))),
    (err) => {
      assert.match([err.message, ...err.opensslErrorStack ?? []].join('\n'),
                   /No such file or directory/);
      return true;
    });
}

{
  // Failures report the loader that actually handled the URI, not the `file`
  // loader that OpenSSL always probes first for URIs without an authority.
  assert.throws(() => createPrivateKey(new URL('pkcs11:object=nope')), {
    code: 'ERR_OSSL_OSSL_STORE_UNSUPPORTED',
  });
}

{
  // Only a genuine URL selects the STORE loader. A plain object that merely
  // exposes `href` and `protocol` must not be treated as one, otherwise an
  // attacker-supplied JWK could redirect the load to a URI of their choosing.
  const file = path.join(tmpdir.path, 'priv.pem');
  const spoofed = {
    kty: 'EC',
    crv: 'P-256',
    x: 'a',
    y: 'b',
    d: 'c',
    href: pathToFileURL(file).href,
    protocol: 'file:',
  };
  assert.throws(() => createPrivateKey({ key: spoofed, format: 'jwk' }), {
    code: 'ERR_CRYPTO_INVALID_JWK',
  });
  assert.throws(() => createPrivateKey(spoofed), {
    code: 'ERR_INVALID_ARG_TYPE',
  });
  assert.throws(
    () => sign(null, data, { key: spoofed, format: 'jwk' }),
    { code: 'ERR_CRYPTO_INVALID_JWK' });
}

{
  // Read the URI from the URL's private state. Public accessors on a branded
  // subclass must not redirect a previously created URL to a different key.
  const trustedHref = pathToFileURL(
    path.join(tmpdir.path, 'priv.pem')).href;
  const redirectHref = pathToFileURL(
    path.join(tmpdir.path, 'rsa.pem')).href;
  class RedirectingURL extends URL {
    get href() { return redirectHref; }
  }
  const subclassURL = new RedirectingURL(trustedHref);
  assert.strictEqual(
    URL.prototype.toString.call(subclassURL), trustedHref);
  assert.strictEqual(
    createPrivateKey(subclassURL).asymmetricKeyType, 'ed25519');

  // The same applies when the accessor is replaced on URL.prototype.
  const prototypeURL = new URL(trustedHref);
  const hrefDescriptor = Object.getOwnPropertyDescriptor(URL.prototype, 'href');
  try {
    Object.defineProperty(URL.prototype, 'href', {
      ...hrefDescriptor,
      get() { return redirectHref; },
    });
    assert.strictEqual(
      URL.prototype.toString.call(prototypeURL), trustedHref);
    assert.strictEqual(
      createPrivateKey(prototypeURL).asymmetricKeyType, 'ed25519');
  } finally {
    Object.defineProperty(URL.prototype, 'href', hrefDescriptor);
  }

  // Replacing `protocol` must not turn an opaque STORE URI into a file path.
  const filePathname = pathToFileURL(
    path.join(tmpdir.path, 'priv.pem')).pathname;
  const opaqueURL = new URL(`pkcs11:${filePathname}`);
  const protocolDescriptor = Object.getOwnPropertyDescriptor(
    URL.prototype, 'protocol');
  try {
    Object.defineProperty(URL.prototype, 'protocol', {
      ...protocolDescriptor,
      get() { return 'file:'; },
    });
    assert.throws(() => createPrivateKey(opaqueURL), {
      code: 'ERR_OSSL_OSSL_STORE_UNSUPPORTED',
    });
  } finally {
    Object.defineProperty(URL.prototype, 'protocol', protocolDescriptor);
  }
}

{
  // The URI is handed to OpenSSL as a NUL-terminated C string, so an embedded
  // NUL must be rejected rather than silently truncating the path.
  const file = path.join(tmpdir.path, 'priv.pem');
  assert.throws(
    () => createPrivateKey(new URL(`${pathToFileURL(file).href}%00.txt`)), {
      code: 'ERR_INVALID_ARG_VALUE',
    });

  // Same for the property query.
  assert.throws(() => createPrivateKey({
    key: pathToFileURL(file),
    properties: `provider=def${'\u0000'}ault`,
  }), {
    code: 'ERR_INVALID_ARG_VALUE',
  });
}